Compare commits

..

40 Commits

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

* feat: nestjs config package added

* test: fix all broken test case

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

* test: fix test case failure

* feat: update infra configs mutation added

* feat: utilise ConfigService in util functions

* chore: remove saml stuff

* feat: removed saml stuffs

* fix: config service precedence

* fix: mailer module init with right env value

* feat: added mutations and query

* feat: add query infra-configs

* fix: mailer module init issue

* chore: smtp url validation added

* fix: all sso disabling is handled

* fix: pnpm i without db connection

* fix: allowedAuthProviders and enableAndDisableSSO

* fix: validateSMTPUrl check

* feat: get api added for fetch provider list

* feat: feedback resolve

* chore: update code comments

* fix: uppercase issue of VITE_ALLOWED_AUTH_PROVIDERS

* chore: update lockfile

* fix: add validation checks for MAILER_ADDRESS_FROM

* test: fix test case

* chore: feedback resolve

* chore: renamed an enum

* chore: app shutdown way changed

---------

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

* feat: completed addition of new data field in TeamCollection

* feat: completed addition of new data field in UserCollections

* chore: updated all tests in team-collection module

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

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

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

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

* chore: fixed edgecases for data being null

* chore: resolved issue with team-request testcases

* chore: completed changes requested in PR review

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

View File

@@ -66,6 +66,7 @@ services:
# The service that spins up all 3 services at once in one container
hoppscotch-aio:
container_name: hoppscotch-aio
restart: unless-stopped
build:
dockerfile: prod.Dockerfile
context: .

View File

@@ -32,6 +32,9 @@
"lint-staged": "12.4.0"
},
"pnpm": {
"overrides": {
"vue": "3.3.9"
},
"packageExtensions": {
"httpsnippet@^3.0.1": {
"peerDependencies": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/apollo": "^12.0.9",
"@nestjs/common": "^10.2.6",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.6",
"@nestjs/graphql": "^12.0.9",
"@nestjs/jwt": "^10.1.1",

View File

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

View File

@@ -209,3 +209,12 @@ enum TeamMemberRole {
VIEWER
EDITOR
}
model InfraConfig {
id String @id @default(cuid())
name String @unique
value String?
active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}

View File

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

View File

@@ -16,6 +16,7 @@ import {
USER_ALREADY_INVITED,
} from '../errors';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@@ -27,6 +28,7 @@ const mockTeamInvitationService = mockDeep<TeamInvitationService>();
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockMailerService = mockDeep<MailerService>();
const mockShortcodeService = mockDeep<ShortcodeService>();
const mockConfigService = mockDeep<ConfigService>();
const adminService = new AdminService(
mockUserService,
@@ -39,6 +41,7 @@ const adminService = new AdminService(
mockPrisma as any,
mockMailerService,
mockShortcodeService,
mockConfigService,
);
const invitedUsers: InvitedUsers[] = [

View File

@@ -25,6 +25,7 @@ import { TeamEnvironmentsService } from '../team-environments/team-environments.
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
import { TeamMemberRole } from '../team/team.model';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AdminService {
@@ -39,6 +40,7 @@ export class AdminService {
private readonly prisma: PrismaService,
private readonly mailerService: MailerService,
private readonly shortcodeService: ShortcodeService,
private readonly configService: ConfigService,
) {}
/**
@@ -79,7 +81,7 @@ export class AdminService {
template: 'user-invitation',
variables: {
inviteeEmail: inviteeEmail,
magicLink: `${process.env.VITE_BASE_URL}`,
magicLink: `${this.configService.get('VITE_BASE_URL')}`,
},
});
} catch (e) {

View File

@@ -1,5 +1,12 @@
import { UseGuards } from '@nestjs/common';
import { Args, ID, Query, ResolveField, Resolver } from '@nestjs/graphql';
import {
Args,
ID,
Mutation,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { Infra } from './infra.model';
import { AdminService } from './admin.service';
@@ -16,11 +23,21 @@ import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { GqlAdmin } from './decorators/gql-admin.decorator';
import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model';
import { InfraConfig } from 'src/infra-config/infra-config.model';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
import {
EnableAndDisableSSOArgs,
InfraConfigArgs,
} from 'src/infra-config/input-args';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Infra)
export class InfraResolver {
constructor(private adminService: AdminService) {}
constructor(
private adminService: AdminService,
private infraConfigService: InfraConfigService,
) {}
@Query(() => Infra, {
description: 'Fetch details of the Infrastructure',
@@ -222,4 +239,76 @@ export class InfraResolver {
userEmail,
);
}
@Query(() => [InfraConfig], {
description: 'Retrieve configuration details for the instance',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async infraConfigs(
@Args({
name: 'configNames',
type: () => [InfraConfigEnumForClient],
description: 'Configs to fetch',
})
names: InfraConfigEnumForClient[],
) {
const infraConfigs = await this.infraConfigService.getMany(names);
if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);
return infraConfigs.right;
}
@Query(() => [String], {
description: 'Allowed Auth Provider list',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
allowedAuthProviders() {
return this.infraConfigService.getAllowedAuthProviders();
}
/* Mutations */
@Mutation(() => [InfraConfig], {
description: 'Update Infra Configs',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async updateInfraConfigs(
@Args({
name: 'infraConfigs',
type: () => [InfraConfigArgs],
description: 'InfraConfigs to update',
})
infraConfigs: InfraConfigArgs[],
) {
const updatedRes = await this.infraConfigService.updateMany(infraConfigs);
if (E.isLeft(updatedRes)) throwErr(updatedRes.left);
return updatedRes.right;
}
@Mutation(() => Boolean, {
description: 'Reset Infra Configs with default values (.env)',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async resetInfraConfigs() {
const resetRes = await this.infraConfigService.reset();
if (E.isLeft(resetRes)) throwErr(resetRes.left);
return true;
}
@Mutation(() => Boolean, {
description: 'Enable or Disable SSO for login/signup',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async enableAndDisableSSO(
@Args({
name: 'providerInfo',
type: () => [EnableAndDisableSSOArgs],
description: 'SSO provider and status',
})
providerInfo: EnableAndDisableSSOArgs[],
) {
const isUpdated = await this.infraConfigService.enableAndDisableSSO(providerInfo);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;
}
}

View File

@@ -20,51 +20,69 @@ import { ShortcodeModule } from './shortcode/shortcode.module';
import { COOKIES_NOT_FOUND } from './errors';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { InfraConfigModule } from './infra-config/infra-config.module';
import { loadInfraConfiguration } from './infra-config/helper';
import { MailerModule } from './mailer/mailer.module';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
buildSchemaOptions: {
numberScalarMode: 'integer',
},
playground: process.env.PRODUCTION !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
},
},
context: ({ req, res, connection }) => ({
req,
res,
connection,
}),
driver: ApolloDriver,
ConfigModule.forRoot({
isGlobal: true,
load: [async () => loadInfraConfiguration()],
}),
ThrottlerModule.forRoot([
{
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
playground: configService.get('PRODUCTION') !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
},
},
context: ({ req, res, connection }) => ({
req,
res,
connection,
}),
};
},
]),
}),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => [
{
ttl: +configService.get('RATE_LIMIT_TTL'),
limit: +configService.get('RATE_LIMIT_MAX'),
},
],
}),
MailerModule.register(),
UserModule,
AuthModule,
AuthModule.register(),
AdminModule,
UserSettingsModule,
UserEnvironmentsModule,
@@ -77,6 +95,7 @@ import { AppController } from './app.controller';
TeamInvitationModule,
UserCollectionModule,
ShortcodeModule,
InfraConfigModule,
],
providers: [GQLComplexityPlugin],
controllers: [AppController],

View File

@@ -2,7 +2,6 @@ import {
Body,
Controller,
Get,
InternalServerErrorException,
Post,
Query,
Request,
@@ -31,11 +30,21 @@ import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' })
export class AuthController {
constructor(private authService: AuthService) {}
constructor(
private authService: AuthService,
private configService: ConfigService,
) {}
@Get('providers')
async getAuthProviders() {
const providers = await this.authService.getAuthProviders();
return { providers };
}
/**
** Route to initiate magic-link auth for a users email
@@ -45,8 +54,14 @@ export class AuthController {
@Body() authData: SignInMagicDto,
@Query('origin') origin: string,
) {
if (!authProviderCheck(AuthProvider.EMAIL))
if (
!authProviderCheck(
AuthProvider.EMAIL,
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
)
) {
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
}
const deviceIdToken = await this.authService.signInMagicLink(
authData.email,

View File

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

View File

@@ -21,15 +21,26 @@ import { VerifyMagicDto } from './dto/verify-magic.dto';
import { DateTime } from 'luxon';
import * as argon2 from 'argon2';
import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
const mockPrisma = mockDeep<PrismaService>();
const mockUser = mockDeep<UserService>();
const mockJWT = mockDeep<JwtService>();
const mockMailer = mockDeep<MailerService>();
const mockConfigService = mockDeep<ConfigService>();
const mockInfraConfigService = mockDeep<InfraConfigService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const authService = new AuthService(mockUser, mockPrisma, mockJWT, mockMailer);
const authService = new AuthService(
mockUser,
mockPrisma,
mockJWT,
mockMailer,
mockConfigService,
mockInfraConfigService,
);
const currentTime = new Date();
@@ -91,6 +102,8 @@ describe('signInMagicLink', () => {
mockUser.createUserViaMagicLink.mockResolvedValue(user);
// create new entry in VerificationToken table
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
// Read env variable 'MAGIC_LINK_TOKEN_VALIDITY' from config service
mockConfigService.get.mockReturnValue('3');
const result = await authService.signInMagicLink(
'dwight@dundermifflin.com',

View File

@@ -28,6 +28,8 @@ import { AuthError } from 'src/types/AuthError';
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
import { VerificationToken } from '@prisma/client';
import { Origin } from './helper';
import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
@Injectable()
export class AuthService {
@@ -36,6 +38,8 @@ export class AuthService {
private prismaService: PrismaService,
private jwtService: JwtService,
private readonly mailerService: MailerService,
private readonly configService: ConfigService,
private infraConfigService: InfraConfigService,
) {}
/**
@@ -46,10 +50,12 @@ export class AuthService {
*/
private async generateMagicLinkTokens(user: AuthUser) {
const salt = await bcrypt.genSalt(
parseInt(process.env.TOKEN_SALT_COMPLEXITY),
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')),
);
const expiresOn = DateTime.now()
.plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) })
.plus({
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')),
})
.toISO()
.toString();
@@ -95,13 +101,13 @@ export class AuthService {
*/
private async generateRefreshToken(userUid: string) {
const refreshTokenPayload: RefreshTokenPayload = {
iss: process.env.VITE_BASE_URL,
iss: this.configService.get('VITE_BASE_URL'),
sub: userUid,
aud: [process.env.VITE_BASE_URL],
aud: [this.configService.get('VITE_BASE_URL')],
};
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days
expiresIn: this.configService.get('REFRESH_TOKEN_VALIDITY'), //7 Days
});
const refreshTokenHash = await argon2.hash(refreshToken);
@@ -127,9 +133,9 @@ export class AuthService {
*/
async generateAuthTokens(userUid: string) {
const accessTokenPayload: AccessTokenPayload = {
iss: process.env.VITE_BASE_URL,
iss: this.configService.get('VITE_BASE_URL'),
sub: userUid,
aud: [process.env.VITE_BASE_URL],
aud: [this.configService.get('VITE_BASE_URL')],
};
const refreshToken = await this.generateRefreshToken(userUid);
@@ -137,7 +143,7 @@ export class AuthService {
return E.right(<AuthTokens>{
access_token: await this.jwtService.sign(accessTokenPayload, {
expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
expiresIn: this.configService.get('ACCESS_TOKEN_VALIDITY'), //1 Day
}),
refresh_token: refreshToken.right,
});
@@ -218,14 +224,14 @@ export class AuthService {
let url: string;
switch (origin) {
case Origin.ADMIN:
url = process.env.VITE_ADMIN_URL;
url = this.configService.get('VITE_ADMIN_URL');
break;
case Origin.APP:
url = process.env.VITE_BASE_URL;
url = this.configService.get('VITE_BASE_URL');
break;
default:
// if origin is invalid by default set URL to Hoppscotch-App
url = process.env.VITE_BASE_URL;
url = this.configService.get('VITE_BASE_URL');
}
await this.mailerService.sendEmail(email, {
@@ -377,4 +383,8 @@ export class AuthService {
return E.right(<IsAdmin>{ isAdmin: false });
}
getAuthProviders() {
return this.infraConfigService.getAllowedAuthProviders();
}
}

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { Response } from 'express';
import * as cookie from 'cookie';
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
import { throwErr } from 'src/utils';
import { ConfigService } from '@nestjs/config';
enum AuthTokenType {
ACCESS_TOKEN = 'access_token',
@@ -45,15 +46,17 @@ export const authCookieHandler = (
redirect: boolean,
redirectUrl: string | null,
) => {
const configService = new ConfigService();
const currentTime = DateTime.now();
const accessTokenValidity = currentTime
.plus({
milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY),
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')),
})
.toMillis();
const refreshTokenValidity = currentTime
.plus({
milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY),
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')),
})
.toMillis();
@@ -75,10 +78,12 @@ export const authCookieHandler = (
}
// check to see if redirectUrl is a whitelisted url
const whitelistedOrigins = process.env.WHITELISTED_ORIGINS.split(',');
const whitelistedOrigins = configService
.get('WHITELISTED_ORIGINS')
.split(',');
if (!whitelistedOrigins.includes(redirectUrl))
// if it is not redirect by default to REDIRECT_URL
redirectUrl = process.env.REDIRECT_URL;
redirectUrl = configService.get('REDIRECT_URL');
return res.status(HttpStatus.OK).redirect(redirectUrl);
};
@@ -112,13 +117,16 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
* @param provider Provider we want to check the presence of
* @returns Boolean if provider specified is present or not
*/
export function authProviderCheck(provider: string) {
export function authProviderCheck(
provider: string,
VITE_ALLOWED_AUTH_PROVIDERS: string,
) {
if (!provider) {
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
}
const envVariables = process.env.VITE_ALLOWED_AUTH_PROVIDERS
? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(),
)
: [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -644,3 +644,41 @@ export const SHORTCODE_INVALID_PROPERTIES_JSON =
*/
export const SHORTCODE_PROPERTIES_NOT_FOUND =
'shortcode/properties_not_found' as const;
/**
* Infra Config not found
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const;
/**
* Infra Config update failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_UPDATE_FAILED = 'infra_config/update_failed' as const;
/**
* Infra Config not listed for onModuleInit creation
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_LISTED =
'infra_config/properly_not_listed' as const;
/**
* Infra Config reset failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_RESET_FAILED = 'infra_config/reset_failed' as const;
/**
* Infra Config invalid input for Config variable
* (InfraConfigService)
*/
export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
/**
* Error message for when the database table does not exist
* (InfraConfigService)
*/
export const DATABASE_TABLE_NOT_EXIST =
'Database migration not performed. Please check the FAQ for assistance: https://docs.hoppscotch.io/support/faq';

View File

@@ -0,0 +1,44 @@
import { PrismaService } from 'src/prisma/prisma.service';
export enum ServiceStatus {
ENABLE = 'ENABLE',
DISABLE = 'DISABLE',
}
/**
* Load environment variables from the database and set them in the process
*
* @Description Fetch the 'infra_config' table from the database and return it as an object
* (ConfigModule will set the environment variables in the process)
*/
export async function loadInfraConfiguration() {
try {
const prisma = new PrismaService();
const infraConfigs = await prisma.infraConfig.findMany();
let environmentObject: Record<string, any> = {};
infraConfigs.forEach((infraConfig) => {
environmentObject[infraConfig.name] = infraConfig.value;
});
return { INFRA: environmentObject };
} catch (error) {
// Prisma throw error if 'Can't reach at database server' OR 'Table does not exist'
// Reason for not throwing error is, we want successful build during 'postinstall' and generate dist files
return { INFRA: {} };
}
}
/**
* Stop the app after 5 seconds
* (Docker will re-start the app)
*/
export function stopApp() {
console.log('Stopping app in 5 seconds...');
setTimeout(() => {
console.log('Stopping app now...');
process.kill(process.pid, 'SIGTERM');
}, 5000);
}

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigService } from './infra-config.service';
import {
InfraConfigEnum,
InfraConfigEnumForClient,
} from 'src/types/InfraConfig';
import { INFRA_CONFIG_NOT_FOUND, INFRA_CONFIG_UPDATE_FAILED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import * as helper from './helper';
const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const infraConfigService = new InfraConfigService(
mockPrisma,
mockConfigService,
);
beforeEach(() => {
mockReset(mockPrisma);
});
describe('InfraConfigService', () => {
describe('update', () => {
it('should update the infra config', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value);
expect(result).toEqualRight({ name, value });
});
it('should pass correct params to prisma update', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
await infraConfigService.update(name, value);
expect(mockPrisma.infraConfig.update).toHaveBeenCalledWith({
where: { name },
data: { value },
});
expect(mockPrisma.infraConfig.update).toHaveBeenCalledTimes(1);
});
it('should throw an error if the infra config update failed', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockRejectedValueOnce('null');
const result = await infraConfigService.update(name, value);
expect(result).toEqualLeft(INFRA_CONFIG_UPDATE_FAILED);
});
});
describe('get', () => {
it('should get the infra config', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
const result = await infraConfigService.get(name);
expect(result).toEqualRight({ name, value });
});
it('should pass correct params to prisma findUnique', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID;
await infraConfigService.get(name);
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledWith({
where: { name },
});
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledTimes(1);
});
it('should throw an error if the infra config does not exist', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID;
mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null');
const result = await infraConfigService.get(name);
expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND);
});
});
});

View File

@@ -0,0 +1,312 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InfraConfig } from './infra-config.model';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfig as DBInfraConfig } from '@prisma/client';
import * as E from 'fp-ts/Either';
import {
InfraConfigEnum,
InfraConfigEnumForClient,
} from 'src/types/InfraConfig';
import {
AUTH_PROVIDER_NOT_SPECIFIED,
DATABASE_TABLE_NOT_EXIST,
INFRA_CONFIG_INVALID_INPUT,
INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_NOT_LISTED,
INFRA_CONFIG_RESET_FAILED,
INFRA_CONFIG_UPDATE_FAILED,
} from 'src/errors';
import { throwErr, validateEmail, validateSMTPUrl } from 'src/utils';
import { ConfigService } from '@nestjs/config';
import { ServiceStatus, stopApp } from './helper';
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
@Injectable()
export class InfraConfigService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
async onModuleInit() {
await this.initializeInfraConfigTable();
}
getDefaultInfraConfigs(): { name: InfraConfigEnum; value: string }[] {
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
name: InfraConfigEnum.MAILER_SMTP_URL,
value: process.env.MAILER_SMTP_URL,
},
{
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: process.env.GOOGLE_CLIENT_ID,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
value: process.env.GOOGLE_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_ID,
value: process.env.GITHUB_CLIENT_ID,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
value: process.env.GITHUB_CLIENT_SECRET,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
value: process.env.MICROSOFT_CLIENT_ID,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
value: process.env.MICROSOFT_CLIENT_SECRET,
},
{
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: process.env.VITE_ALLOWED_AUTH_PROVIDERS.toLocaleUpperCase(),
},
];
return infraConfigDefaultObjs;
}
/**
* Initialize the 'infra_config' table with values from .env
* @description This function create rows 'infra_config' in very first time (only once)
*/
async initializeInfraConfigTable() {
try {
// Get all the 'names' of the properties to be saved in the 'infra_config' table
const enumValues = Object.values(InfraConfigEnum);
// Fetch the default values (value in .env) for configs to be saved in 'infra_config' table
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
// Check if all the 'names' are listed in the default values
if (enumValues.length !== infraConfigDefaultObjs.length) {
throw new Error(INFRA_CONFIG_NOT_LISTED);
}
// Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table
const dbInfraConfigs = await this.prisma.infraConfig.findMany();
const propsToInsert = infraConfigDefaultObjs.filter(
(p) => !dbInfraConfigs.find((e) => e.name === p.name),
);
if (propsToInsert.length > 0) {
await this.prisma.infraConfig.createMany({ data: propsToInsert });
stopApp();
}
} catch (error) {
if (error.code === 'P1001') {
// Prisma error code for 'Can't reach at database server'
// We're not throwing error here because we want to allow the app to run 'pnpm install'
} else if (error.code === 'P2021') {
// Prisma error code for 'Table does not exist'
throwErr(DATABASE_TABLE_NOT_EXIST);
} else {
throwErr(error);
}
}
}
/**
* Typecast a database InfraConfig to a InfraConfig model
* @param dbInfraConfig database InfraConfig
* @returns InfraConfig model
*/
cast(dbInfraConfig: DBInfraConfig) {
return <InfraConfig>{
name: dbInfraConfig.name,
value: dbInfraConfig.value,
};
}
/**
* Update InfraConfig by name
* @param name Name of the InfraConfig
* @param value Value of the InfraConfig
* @returns InfraConfig model
*/
async update(
name: InfraConfigEnumForClient | InfraConfigEnum,
value: string,
) {
const isValidate = this.validateEnvValues([{ name, value }]);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
try {
const infraConfig = await this.prisma.infraConfig.update({
where: { name },
data: { value },
});
stopApp();
return E.right(this.cast(infraConfig));
} catch (e) {
return E.left(INFRA_CONFIG_UPDATE_FAILED);
}
}
/**
* Update InfraConfigs by name
* @param infraConfigs InfraConfigs to update
* @returns InfraConfig model
*/
async updateMany(infraConfigs: InfraConfigArgs[]) {
const isValidate = this.validateEnvValues(infraConfigs);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
try {
await this.prisma.$transaction(async (tx) => {
for (let i = 0; i < infraConfigs.length; i++) {
await tx.infraConfig.update({
where: { name: infraConfigs[i].name },
data: { value: infraConfigs[i].value },
});
}
});
stopApp();
return E.right(infraConfigs);
} catch (e) {
return E.left(INFRA_CONFIG_UPDATE_FAILED);
}
}
/**
* Enable or Disable SSO for login/signup
* @param provider Auth Provider to enable or disable
* @param status Status to enable or disable
* @returns Either true or an error
*/
async enableAndDisableSSO(providerInfo: EnableAndDisableSSOArgs[]) {
const allowedAuthProviders = this.configService
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
.split(',');
let updatedAuthProviders = allowedAuthProviders;
providerInfo.forEach(({ provider, status }) => {
if (status === ServiceStatus.ENABLE) {
updatedAuthProviders.push(provider);
} else if (status === ServiceStatus.DISABLE) {
updatedAuthProviders = updatedAuthProviders.filter(
(p) => p !== provider,
);
}
});
updatedAuthProviders = [...new Set(updatedAuthProviders)];
if (updatedAuthProviders.length === 0) {
return E.left(AUTH_PROVIDER_NOT_SPECIFIED);
}
const isUpdated = await this.update(
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
updatedAuthProviders.join(','),
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/**
* Get InfraConfig by name
* @param name Name of the InfraConfig
* @returns InfraConfig model
*/
async get(name: InfraConfigEnumForClient) {
try {
const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({
where: { name },
});
return E.right(this.cast(infraConfig));
} catch (e) {
return E.left(INFRA_CONFIG_NOT_FOUND);
}
}
/**
* Get InfraConfigs by names
* @param names Names of the InfraConfigs
* @returns InfraConfig model
*/
async getMany(names: InfraConfigEnumForClient[]) {
try {
const infraConfigs = await this.prisma.infraConfig.findMany({
where: { name: { in: names } },
});
return E.right(infraConfigs.map((p) => this.cast(p)));
} catch (e) {
return E.left(INFRA_CONFIG_NOT_FOUND);
}
}
/**
* Get allowed auth providers for login/signup
* @returns string[]
*/
getAllowedAuthProviders() {
return this.configService
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
.split(',');
}
/**
* Reset all the InfraConfigs to their default values (from .env)
*/
async reset() {
try {
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
await this.prisma.infraConfig.deleteMany({
where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } },
});
await this.prisma.infraConfig.createMany({
data: infraConfigDefaultObjs,
});
stopApp();
return E.right(true);
} catch (e) {
return E.left(INFRA_CONFIG_RESET_FAILED);
}
}
validateEnvValues(
infraConfigs: {
name: InfraConfigEnumForClient | InfraConfigEnum;
value: string;
}[],
) {
for (let i = 0; i < infraConfigs.length; i++) {
switch (infraConfigs[i].name) {
case InfraConfigEnumForClient.MAILER_SMTP_URL:
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnumForClient.MAILER_ADDRESS_FROM:
const isValidEmail = validateEmail(infraConfigs[i].value);
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
default:
break;
}
}
return E.right(true);
}
}

View File

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

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { MailerService } from './mailer.service';
@@ -7,24 +7,42 @@ import {
MAILER_FROM_ADDRESS_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper';
@Global()
@Module({
imports: [
NestMailerModule.forRoot({
transport:
process.env.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
defaults: {
from:
process.env.MAILER_ADDRESS_FROM ??
throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
},
template: {
dir: __dirname + '/templates',
adapter: new HandlebarsAdapter(),
},
}),
],
imports: [],
providers: [MailerService],
exports: [MailerService],
})
export class MailerModule {}
export class MailerModule {
static async register() {
const env = await loadInfraConfiguration();
let mailerSmtpUrl = env.INFRA.MAILER_SMTP_URL;
let mailerAddressFrom = env.INFRA.MAILER_ADDRESS_FROM;
if (!env.INFRA.MAILER_SMTP_URL || !env.INFRA.MAILER_ADDRESS_FROM) {
const config = new ConfigService();
mailerSmtpUrl = config.get('MAILER_SMTP_URL');
mailerAddressFrom = config.get('MAILER_ADDRESS_FROM');
}
return {
module: MailerModule,
imports: [
NestMailerModule.forRoot({
transport: mailerSmtpUrl ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
defaults: {
from: mailerAddressFrom ?? throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
},
template: {
dir: __dirname + '/templates',
adapter: new HandlebarsAdapter(),
},
}),
],
};
}
}

View File

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

View File

@@ -504,20 +504,24 @@ describe('ShortcodeService', () => {
);
expect(result).toEqual(<ShortcodeWithUserEmail[]>[
{
id: shortcodes[0].id,
request: JSON.stringify(shortcodes[0].request),
properties: JSON.stringify(shortcodes[0].embedProperties),
createdOn: shortcodes[0].createdOn,
id: shortcodesWithUserEmail[0].id,
request: JSON.stringify(shortcodesWithUserEmail[0].request),
properties: JSON.stringify(
shortcodesWithUserEmail[0].embedProperties,
),
createdOn: shortcodesWithUserEmail[0].createdOn,
creator: {
uid: user.uid,
email: user.email,
},
},
{
id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn,
id: shortcodesWithUserEmail[1].id,
request: JSON.stringify(shortcodesWithUserEmail[1].request),
properties: JSON.stringify(
shortcodesWithUserEmail[1].embedProperties,
),
createdOn: shortcodesWithUserEmail[1].createdOn,
creator: {
uid: user.uid,
email: user.email,

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
export enum InfraConfigEnum {
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
}
export enum InfraConfigEnumForClient {
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
}

View File

@@ -131,6 +131,26 @@ export const validateEmail = (email: string) => {
).test(email);
};
/**
* Checks to see if the URL is valid or not
* @param url The URL to validate
* @returns boolean
*/
export const validateSMTPUrl = (url: string) => {
// Possible valid formats
// smtp(s)://mail.example.com
// smtp(s)://user:pass@mail.example.com
// smtp(s)://mail.example.com:587
// smtp(s)://user:pass@mail.example.com:587
if (!url || url.length === 0) return false;
const regex =
/^(smtp|smtps):\/\/(?:([^:]+):([^@]+)@)?((?!\.)[^:]+)(?::(\d+))?$/;
if (regex.test(url)) return true;
return false;
};
/**
* String to JSON parser
* @param {str} str The string to parse
@@ -161,21 +181,23 @@ export function isValidLength(title: string, length: number) {
/**
* This function is called by bootstrap() in main.ts
* It checks if the "VITE_ALLOWED_AUTH_PROVIDERS" environment variable is properly set or not.
* 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')) {
export function checkEnvironmentAuthProvider(
VITE_ALLOWED_AUTH_PROVIDERS: string,
) {
if (!VITE_ALLOWED_AUTH_PROVIDERS) {
throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS);
}
if (process.env.VITE_ALLOWED_AUTH_PROVIDERS === '') {
if (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 givenAuthProviders = VITE_ALLOWED_AUTH_PROVIDERS.split(',').map(
(provider) => provider.toLocaleUpperCase(),
);
const supportedAuthProviders = Object.values(AuthProvider).map(
(provider: string) => provider.toLocaleUpperCase(),
);

View File

@@ -147,7 +147,7 @@ module.exports = {
// The glob patterns Jest uses to detect test files
testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
"**/src/__tests__/**/*.*.ts",
"**/src/__tests__/commands/**/*.*.ts",
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped

View File

@@ -19,8 +19,9 @@
"debugger": "node debugger.js 9999",
"prepublish": "pnpm exec tsup",
"prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write",
"test": "pnpm run build && jest && rm -rf dist",
"do-typecheck": "pnpm exec tsc --noEmit",
"test": "pnpm run build && jest && rm -rf dist"
"do-test": "pnpm test"
},
"keywords": [
"cli",

View File

@@ -1,63 +1,64 @@
import { ExecException } from "child_process";
import { HoppErrorCode } from "../../types/errors";
import { execAsync, getErrorCode, getTestJsonFilePath } from "../utils";
import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils";
describe("Test 'hopp test <file>' command:", () => {
test("No collection file path provided.", async () => {
const cmd = `node ./bin/hopp test`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const args = "test";
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Collection file not found.", async () => {
const cmd = `node ./bin/hopp test notfound.json`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const args = "test notfound.json";
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
test("Collection file is invalid JSON.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
const args = `test ${getTestJsonFilePath(
"malformed-collection.json"
)}`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
});
test("Malformed collection file.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
const args = `test ${getTestJsonFilePath(
"malformed-collection2.json"
)}`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
});
test("Invalid arguement.", async () => {
const cmd = `node ./bin/hopp invalid-arg`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const args = "invalid-arg";
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Collection file not JSON type.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const args = `test ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
});
test("Some errors occured (exit code 1).", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("fails.json")}`;
const { error } = await execAsync(cmd);
const args = `test ${getTestJsonFilePath("fails.json")}`;
const { error } = await runCLI(args);
expect(error).not.toBeNull();
expect(error).toMatchObject(<ExecException>{
@@ -66,76 +67,83 @@ describe("Test 'hopp test <file>' command:", () => {
});
test("No errors occured (exit code 0).", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("passes.json")}`;
const { error } = await execAsync(cmd);
const args = `test ${getTestJsonFilePath("passes.json")}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Supports inheriting headers and authorization set at the root collection", async () => {
const args = `test ${getTestJsonFilePath("collection-level-headers-auth.json")}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
})
});
describe("Test 'hopp test <file> --env <file>' command:", () => {
const VALID_TEST_CMD = `node ./bin/hopp test ${getTestJsonFilePath(
const VALID_TEST_ARGS = `test ${getTestJsonFilePath(
"passes.json"
)}`;
test("No env file path provided.", async () => {
const cmd = `${VALID_TEST_CMD} --env`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const args = `${VALID_TEST_ARGS} --env`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("ENV file not JSON type.", async () => {
const cmd = `${VALID_TEST_CMD} --env ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
});
test("ENV file not found.", async () => {
const cmd = `${VALID_TEST_CMD} --env notfound.json`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const args = `${VALID_TEST_ARGS} --env notfound.json`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
test("No errors occured (exit code 0).", async () => {
const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json");
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json");
const cmd = `node ./bin/hopp test ${TESTS_PATH} --env ${ENV_PATH}`;
const { error } = await execAsync(cmd);
const args = `test ${TESTS_PATH} --env ${ENV_PATH}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
});
describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
const VALID_TEST_CMD = `node ./bin/hopp test ${getTestJsonFilePath(
const VALID_TEST_ARGS = `test ${getTestJsonFilePath(
"passes.json"
)}`;
test("No value passed to delay flag.", async () => {
const cmd = `${VALID_TEST_CMD} --delay`;
const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr);
const args = `${VALID_TEST_ARGS} --delay`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Invalid value passed to delay flag.", async () => {
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`;
const { stderr } = await execAsync(cmd);
const args = `${VALID_TEST_ARGS} --delay 'NaN'`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
console.log("invalid value thing", out)
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Valid value passed to delay flag.", async () => {
const cmd = `${VALID_TEST_CMD} --delay 1`;
const { error } = await execAsync(cmd);
const args = `${VALID_TEST_ARGS} --delay 1`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});

View File

@@ -0,0 +1,221 @@
[
{
"v": 1,
"name": "CollectionA",
"folders": [
{
"v": 1,
"name": "FolderA",
"folders": [
{
"v": 1,
"name": "FolderB",
"folders": [
{
"v": 1,
"name": "FolderC",
"folders": [],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestD",
"params": [],
"headers": [
{
"active": true,
"key": "X-Test-Header",
"value": "Overriden at RequestD"
}
],
"method": "GET",
"auth": {
"authType": "basic",
"authActive": true,
"username": "username",
"password": "password"
},
"preRequestScript": "",
"testScript": "pw.test(\"Overrides auth and headers set at the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Overriden at RequestD\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Basic dXNlcm5hbWU6cGFzc3dvcmQ=\");\n});",
"body": {
"contentType": null,
"body": null
}
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestC",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Overriden at FolderB\");\n pw.expect(pw.response.body.headers[\"key\"]).toBe(\"test-key\");\n});",
"body": {
"contentType": null,
"body": null
}
}
],
"auth": {
"authType": "api-key",
"authActive": true,
"addTo": "Headers",
"key": "key",
"value": "test-key"
},
"headers": [
{
"active": true,
"key": "X-Test-Header",
"value": "Overriden at FolderB"
}
]
}
],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestB",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});",
"body": {
"contentType": null,
"body": null
},
"id": "clpttpdq00003qp16kut6doqv"
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestA",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the root collection\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});",
"body": {
"contentType": null,
"body": null
},
"id": "clpttpdq00003qp16kut6doqv"
}
],
"headers": [
{
"active": true,
"key": "X-Test-Header",
"value": "Set at root collection"
}
],
"auth": {
"authType": "bearer",
"authActive": true,
"token": "BearerToken"
}
},
{
"v": 1,
"name": "CollectionB",
"folders": [
{
"v": 1,
"name": "FolderA",
"folders": [],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestB",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the parent folder\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});",
"body": {
"contentType": null,
"body": null
},
"id": "clpttpdq00003qp16kut6doqv"
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
],
"requests": [
{
"v": "1",
"endpoint": "https://echo.hoppscotch.io",
"name": "RequestA",
"params": [],
"headers": [],
"method": "GET",
"auth": {
"authType": "inherit",
"authActive": true
},
"preRequestScript": "",
"testScript": "pw.test(\"Correctly inherits auth and headers from the root collection\", ()=> {\n pw.expect(pw.response.body.headers[\"x-test-header\"]).toBe(\"Set at root collection\");\n pw.expect(pw.response.body.headers[\"authorization\"]).toBe(\"Bearer BearerToken\");\n});",
"body": {
"contentType": null,
"body": null
},
"id": "clpttpdq00003qp16kut6doqv"
}
],
"headers": [
{
"active": true,
"key": "X-Test-Header",
"value": "Set at root collection"
}
],
"auth": {
"authType": "bearer",
"authActive": true,
"token": "BearerToken"
}
}
]

View File

@@ -1,7 +1,6 @@
{
"URL": "https://echo.hoppscotch.io",
"HOST": "echo.hoppscotch.io",
"X-COUNTRY": "IN",
"BODY_VALUE": "body_value",
"BODY_KEY": "body_key"
}

View File

@@ -12,7 +12,7 @@
"method": "POST",
"auth": { "authType": "none", "authActive": true },
"preRequestScript": "",
"testScript": "const HOST = pw.env.get(\"HOST\");\nconst UNSET_ENV = pw.env.get(\"UNSET_ENV\");\nconst EXPECTED_URL = \"https://echo.hoppscotch.io\";\nconst URL = pw.env.get(\"URL\");\nconst X_COUNTRY = pw.env.get(\"X-COUNTRY\");\nconst BODY_VALUE = pw.env.get(\"BODY_VALUE\");\n\n// Check JSON response property\npw.test(\"Check headers properties.\", ()=> {\n pw.expect(pw.response.body.headers.host).toBe(HOST);\n\t pw.expect(pw.response.body.headers[\"x-country\"]).toBe(X_COUNTRY); \n});\n\npw.test(\"Check data properties.\", () => {\n\tconst DATA = pw.response.body.data;\n \n pw.expect(DATA).toBeType(\"string\");\n pw.expect(JSON.parse(DATA).body_key).toBe(BODY_VALUE);\n});\n\npw.test(\"Check request URL.\", () => {\n pw.expect(URL).toBe(EXPECTED_URL);\n})\n\npw.test(\"Check unset ENV.\", () => {\n pw.expect(UNSET_ENV).toBeType(\"undefined\");\n})",
"testScript": "const HOST = pw.env.get(\"HOST\");\nconst UNSET_ENV = pw.env.get(\"UNSET_ENV\");\nconst EXPECTED_URL = \"https://echo.hoppscotch.io\";\nconst URL = pw.env.get(\"URL\");\nconst BODY_VALUE = pw.env.get(\"BODY_VALUE\");\n\n// Check JSON response property\npw.test(\"Check headers properties.\", ()=> {\n pw.expect(pw.response.body.headers.host).toBe(HOST);\n});\n\npw.test(\"Check data properties.\", () => {\n\tconst DATA = pw.response.body.data;\n \n pw.expect(DATA).toBeType(\"string\");\n pw.expect(JSON.parse(DATA).body_key).toBe(BODY_VALUE);\n});\n\npw.test(\"Check request URL.\", () => {\n pw.expect(URL).toBe(EXPECTED_URL);\n})\n\npw.test(\"Check unset ENV.\", () => {\n pw.expect(UNSET_ENV).toBeType(\"undefined\");\n})",
"body": {
"contentType": "application/json",
"body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}"

View File

@@ -1,10 +1,17 @@
import { exec } from "child_process";
import { resolve } from "path";
import { ExecResponse } from "./types";
export const execAsync = (command: string): Promise<ExecResponse> =>
new Promise((resolve) =>
exec(command, (error, stdout, stderr) => resolve({ error, stdout, stderr }))
);
export const runCLI = (args: string): Promise<ExecResponse> =>
{
const CLI_PATH = resolve(__dirname, "../../bin/hopp");
const command = `node ${CLI_PATH} ${args}`
return new Promise((resolve) =>
exec(command, (error, stdout, stderr) => resolve({ error, stdout, stderr }))
);
}
export const trimAnsi = (target: string) => {
const ansiRegex =

View File

@@ -1,8 +1,8 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { HoppCollection } from "@hoppscotch/data";
import { HoppEnvs } from "./request";
export type CollectionRunnerParam = {
collections: HoppCollection<HoppRESTRequest>[];
collections: HoppCollection[];
envs: HoppEnvs;
delay?: number;
};

View File

@@ -33,7 +33,7 @@ export type HoppEnvs = {
export type CollectionStack = {
path: string;
collection: HoppCollection<HoppRESTRequest>;
collection: HoppCollection;
};
export type RequestReport = {

View File

@@ -1,8 +1,4 @@
import {
HoppCollection,
HoppRESTRequest,
isHoppRESTRequest,
} from "@hoppscotch/data";
import { HoppCollection, isHoppRESTRequest } from "@hoppscotch/data";
import * as A from "fp-ts/Array";
import { CommanderError } from "commander";
import { HoppCLIError, HoppErrnoException } from "../types/errors";
@@ -24,9 +20,7 @@ export const hasProperty = <P extends PropertyKey>(
* @returns True, if unknown parameter is valid Hoppscotch REST Collection;
* False, otherwise.
*/
export const isRESTCollection = (
param: unknown
): param is HoppCollection<HoppRESTRequest> => {
export const isRESTCollection = (param: unknown): param is HoppCollection => {
if (!!param && typeof param === "object") {
if (!hasProperty(param, "v") || typeof param.v !== "number") {
return false;
@@ -62,7 +56,6 @@ export const isRESTCollection = (
return false;
};
/**
* Checks if given error data is of type HoppCLIError, based on existence
* of code property.

View File

@@ -1,21 +1,23 @@
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { bold } from "chalk";
import { log } from "console";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
import round from "lodash/round";
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { CollectionRunnerParam } from "../types/collections";
import {
HoppEnvs,
CollectionStack,
RequestReport,
HoppEnvs,
ProcessRequestParams,
RequestReport,
} from "../types/request";
import {
getRequestMetrics,
preProcessRequest,
processRequest,
} from "./request";
import { exceptionColors } from "./getters";
PreRequestMetrics,
RequestMetrics,
TestMetrics,
} from "../types/response";
import { DEFAULT_DURATION_PRECISION } from "./constants";
import {
printErrorsReport,
printFailedTestsReport,
@@ -23,15 +25,14 @@ import {
printRequestsMetrics,
printTestsMetrics,
} from "./display";
import {
PreRequestMetrics,
RequestMetrics,
TestMetrics,
} from "../types/response";
import { getTestMetrics } from "./test";
import { DEFAULT_DURATION_PRECISION } from "./constants";
import { exceptionColors } from "./getters";
import { getPreRequestMetrics } from "./pre-request";
import { CollectionRunnerParam } from "../types/collections";
import {
getRequestMetrics,
preProcessRequest,
processRequest,
} from "./request";
import { getTestMetrics } from "./test";
const { WARN, FAIL } = exceptionColors;
@@ -41,23 +42,23 @@ const { WARN, FAIL } = exceptionColors;
* @param param Data of hopp-collection with hopp-requests, envs to be processed.
* @returns List of report for each processed request.
*/
export const collectionsRunner =
async (param: CollectionRunnerParam): Promise<RequestReport[]> =>
{
const envs: HoppEnvs = param.envs;
const delay = param.delay ?? 0;
const requestsReport: RequestReport[] = [];
const collectionStack: CollectionStack[] = getCollectionStack(
param.collections
);
export const collectionsRunner = async (
param: CollectionRunnerParam
): Promise<RequestReport[]> => {
const envs: HoppEnvs = param.envs;
const delay = param.delay ?? 0;
const requestsReport: RequestReport[] = [];
const collectionStack: CollectionStack[] = getCollectionStack(
param.collections
);
while (collectionStack.length) {
// Pop out top-most collection from stack to be processed.
const { collection, path } = <CollectionStack>collectionStack.pop();
while (collectionStack.length) {
// Pop out top-most collection from stack to be processed.
const { collection, path } = <CollectionStack>collectionStack.pop();
// Processing each request in collection
for (const request of collection.requests) {
const _request = preProcessRequest(request);
const _request = preProcessRequest(request as HoppRESTRequest, collection);
const requestPath = `${path}/${_request.name}`;
const processRequestParams: ProcessRequestParams = {
path: requestPath,
@@ -69,13 +70,13 @@ export const collectionsRunner =
// Request processing initiated message.
log(WARN(`\nRunning: ${bold(requestPath)}`));
// Processing current request.
const result = await processRequest(processRequestParams)();
// Processing current request.
const result = await processRequest(processRequestParams)();
// Updating global & selected envs with new envs from processed-request output.
const { global, selected } = result.envs;
envs.global = global;
envs.selected = selected;
// Updating global & selected envs with new envs from processed-request output.
const { global, selected } = result.envs;
envs.global = global;
envs.selected = selected;
// Storing current request's report.
const requestReport = result.report;
@@ -84,15 +85,30 @@ export const collectionsRunner =
// Pushing remaining folders realted collection to stack.
for (const folder of collection.folders) {
const updatedFolder: HoppCollection = { ...folder }
if (updatedFolder.auth?.authType === "inherit") {
updatedFolder.auth = collection.auth;
}
if (collection.headers?.length) {
// Filter out header entries present in the parent collection under the same name
// This ensures the folder headers take precedence over the collection headers
const filteredHeaders = collection.headers.filter((collectionHeaderEntries) => {
return !updatedFolder.headers.some((folderHeaderEntries) => folderHeaderEntries.key === collectionHeaderEntries.key)
})
updatedFolder.headers.push(...filteredHeaders);
}
collectionStack.push({
path: `${path}/${folder.name}`,
collection: folder,
path: `${path}/${updatedFolder.name}`,
collection: updatedFolder,
});
}
}
return requestsReport;
};
return requestsReport;
};
/**
* Transforms collections to generate collection-stack which describes each collection's
@@ -100,9 +116,7 @@ export const collectionsRunner =
* @param collections Hopp-collection objects to be mapped to collection-stack type.
* @returns Mapped collections to collection-stack.
*/
const getCollectionStack = (
collections: HoppCollection<HoppRESTRequest>[]
): CollectionStack[] =>
const getCollectionStack = (collections: HoppCollection[]): CollectionStack[] =>
pipe(
collections,
A.map(

View File

@@ -2,7 +2,7 @@ import fs from "fs/promises";
import { FormDataEntry } from "../types/request";
import { error } from "../types/errors";
import { isRESTCollection, isHoppErrnoException } from "./checks";
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { HoppCollection } from "@hoppscotch/data";
/**
* Parses array of FormDataEntry to FormData.
@@ -35,20 +35,20 @@ export const parseErrorMessage = (e: unknown) => {
};
export async function readJsonFile(path: string): Promise<unknown> {
if(!path.endsWith('.json')) {
throw error({ code: "INVALID_FILE_TYPE", data: path })
if (!path.endsWith(".json")) {
throw error({ code: "INVALID_FILE_TYPE", data: path });
}
try {
await fs.access(path)
await fs.access(path);
} catch (e) {
throw error({ code: "FILE_NOT_FOUND", path: path })
throw error({ code: "FILE_NOT_FOUND", path: path });
}
try {
return JSON.parse((await fs.readFile(path)).toString())
} catch(e) {
throw error({ code: "UNKNOWN_ERROR", data: e })
return JSON.parse((await fs.readFile(path)).toString());
} catch (e) {
throw error({ code: "UNKNOWN_ERROR", data: e });
}
}
@@ -56,22 +56,24 @@ export async function readJsonFile(path: string): Promise<unknown> {
* Parses collection json file for given path:context.path, and validates
* the parsed collectiona array.
* @param path Collection json file path.
* @returns For successful parsing we get array of HoppCollection<HoppRESTRequest>,
* @returns For successful parsing we get array of HoppCollection,
*/
export async function parseCollectionData(
path: string
): Promise<HoppCollection<HoppRESTRequest>[]> {
let contents = await readJsonFile(path)
): Promise<HoppCollection[]> {
let contents = await readJsonFile(path);
const maybeArrayOfCollections: unknown[] = Array.isArray(contents) ? contents : [contents]
const maybeArrayOfCollections: unknown[] = Array.isArray(contents)
? contents
: [contents];
if(maybeArrayOfCollections.some((x) => !isRESTCollection(x))) {
if (maybeArrayOfCollections.some((x) => !isRESTCollection(x))) {
throw error({
code: "MALFORMED_COLLECTION",
path,
data: "Please check the collection data.",
})
});
}
return maybeArrayOfCollections as HoppCollection<HoppRESTRequest>[]
};
return maybeArrayOfCollections as HoppCollection[];
}

View File

@@ -6,23 +6,24 @@ import {
parseTemplateString,
parseTemplateStringE,
} from "@hoppscotch/data";
import { runPreRequestScript } from "@hoppscotch/js-sandbox";
import { flow, pipe } from "fp-ts/function";
import * as TE from "fp-ts/TaskEither";
import * as E from "fp-ts/Either";
import * as RA from "fp-ts/ReadonlyArray";
import { runPreRequestScript } from "@hoppscotch/js-sandbox/node";
import * as A from "fp-ts/Array";
import * as E from "fp-ts/Either";
import * as O from "fp-ts/Option";
import * as RA from "fp-ts/ReadonlyArray";
import * as TE from "fp-ts/TaskEither";
import { flow, pipe } from "fp-ts/function";
import * as S from "fp-ts/string";
import qs from "qs";
import { EffectiveHoppRESTRequest } from "../interfaces/request";
import { error, HoppCLIError } from "../types/errors";
import { HoppCLIError, error } from "../types/errors";
import { HoppEnvs } from "../types/request";
import { isHoppCLIError } from "./checks";
import { tupleToRecord, arraySort, arrayFlatMap } from "./functions/array";
import { toFormData } from "./mutators";
import { getEffectiveFinalMetaData } from "./getters";
import { PreRequestMetrics } from "../types/response";
import { isHoppCLIError } from "./checks";
import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array";
import { getEffectiveFinalMetaData } from "./getters";
import { toFormData } from "./mutators";
/**
* Runs pre-request-script runner over given request which extracts set ENVs and

View File

@@ -1,31 +1,31 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import axios, { Method } from "axios";
import { URL } from "url";
import * as S from "fp-ts/string";
import * as A from "fp-ts/Array";
import * as T from "fp-ts/Task";
import * as E from "fp-ts/Either";
import * as T from "fp-ts/Task";
import * as TE from "fp-ts/TaskEither";
import { HoppRESTRequest } from "@hoppscotch/data";
import { responseErrors } from "./constants";
import { getDurationInSeconds, getMetaDataPairs } from "./getters";
import { testRunner, getTestScriptParams, hasFailedTestCases } from "./test";
import { RequestConfig, EffectiveHoppRESTRequest } from "../interfaces/request";
import { pipe } from "fp-ts/function";
import * as S from "fp-ts/string";
import { hrtime } from "process";
import { URL } from "url";
import { EffectiveHoppRESTRequest, RequestConfig } from "../interfaces/request";
import { RequestRunnerResponse } from "../interfaces/response";
import { preRequestScriptRunner } from "./pre-request";
import { HoppCLIError, error } from "../types/errors";
import {
HoppEnvs,
ProcessRequestParams,
RequestReport,
} from "../types/request";
import { RequestMetrics } from "../types/response";
import { responseErrors } from "./constants";
import {
printPreRequestRunner,
printRequestRunner,
printTestRunner,
} from "./display";
import { error, HoppCLIError } from "../types/errors";
import { hrtime } from "process";
import { RequestMetrics } from "../types/response";
import { pipe } from "fp-ts/function";
import { getDurationInSeconds, getMetaDataPairs } from "./getters";
import { preRequestScriptRunner } from "./pre-request";
import { getTestScriptParams, hasFailedTestCases, testRunner } from "./test";
// !NOTE: The `config.supported` checks are temporary until OAuth2 and Multipart Forms are supported
@@ -309,9 +309,12 @@ export const processRequest =
* @returns Updated request object free of invalid/missing data.
*/
export const preProcessRequest = (
request: HoppRESTRequest
request: HoppRESTRequest,
collection: HoppCollection,
): HoppRESTRequest => {
const tempRequest = Object.assign({}, request);
const { headers: parentHeaders, auth: parentAuth } = collection;
if (!tempRequest.v) {
tempRequest.v = "1";
}
@@ -327,18 +330,31 @@ export const preProcessRequest = (
if (!tempRequest.params) {
tempRequest.params = [];
}
if (!tempRequest.headers) {
if (parentHeaders?.length) {
// Filter out header entries present in the parent (folder/collection) under the same name
// This ensures the child headers take precedence over the parent headers
const filteredEntries = parentHeaders.filter((parentHeaderEntries) => {
return !tempRequest.headers.some((reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key)
})
tempRequest.headers.push(...filteredEntries);
} else if (!tempRequest.headers) {
tempRequest.headers = [];
}
if (!tempRequest.preRequestScript) {
tempRequest.preRequestScript = "";
}
if (!tempRequest.testScript) {
tempRequest.testScript = "";
}
if (!tempRequest.auth) {
if (tempRequest.auth?.authType === "inherit") {
tempRequest.auth = parentAuth;
} else if (!tempRequest.auth) {
tempRequest.auth = { authActive: false, authType: "none" };
}
if (!tempRequest.body) {
tempRequest.body = { contentType: null, body: null };
}

View File

@@ -1,17 +1,19 @@
import { HoppRESTRequest } from "@hoppscotch/data";
import { execTestScript, TestDescriptor } from "@hoppscotch/js-sandbox";
import { hrtime } from "process";
import { flow, pipe } from "fp-ts/function";
import * as RA from "fp-ts/ReadonlyArray";
import { TestDescriptor } from "@hoppscotch/js-sandbox";
import { runTestScript } from "@hoppscotch/js-sandbox/node";
import * as A from "fp-ts/Array";
import * as TE from "fp-ts/TaskEither";
import * as RA from "fp-ts/ReadonlyArray";
import * as T from "fp-ts/Task";
import * as TE from "fp-ts/TaskEither";
import { flow, pipe } from "fp-ts/function";
import { hrtime } from "process";
import {
RequestRunnerResponse,
TestReport,
TestScriptParams,
} from "../interfaces/response";
import { error, HoppCLIError } from "../types/errors";
import { HoppCLIError, error } from "../types/errors";
import { HoppEnvs } from "../types/request";
import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response";
import { getDurationInSeconds } from "./getters";
@@ -36,7 +38,7 @@ export const testRunner = (
pipe(
TE.of(testScriptData),
TE.chain(({ testScript, response, envs }) =>
execTestScript(testScript, envs, response)
runTestScript(testScript, envs, response)
)
)
),

View File

@@ -69,5 +69,7 @@ module.exports = {
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
eqeqeq: 1,
"no-else-return": 1,
},
}

View File

@@ -5,5 +5,4 @@ module.exports = {
printWidth: 80,
useTabs: false,
tabWidth: 2,
plugins: ["prettier-plugin-tailwindcss"],
}

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width=".88em" height="1em" viewBox="0 0 21 24" class="iconify iconify--fontisto"><path fill="currentColor" d="M12.731 2.751 17.666 5.6a2.138 2.138 0 1 1 2.07 3.548l-.015.003v5.7a2.14 2.14 0 1 1-2.098 3.502l-.002-.002-4.905 2.832a2.14 2.14 0 1 1-4.079.054l-.004.015-4.941-2.844a2.14 2.14 0 1 1-2.067-3.556l.015-.003V9.15a2.14 2.14 0 1 1 1.58-3.926l-.01-.005c.184.106.342.231.479.376l.001.001 4.938-2.85a2.14 2.14 0 1 1 4.096.021l.004-.015zm-.515.877a.766.766 0 0 1-.057.057l-.001.001 6.461 11.19c.026-.009.056-.016.082-.023V9.146a2.14 2.14 0 0 1-1.555-2.603l-.003.015.019-.072zm-3.015.059-.06-.06-4.946 2.852A2.137 2.137 0 0 1 2.749 9.12l-.015.004-.076.021v5.708l.084.023 6.461-11.19zm2.076.507a2.164 2.164 0 0 1-1.207-.004l.015.004-6.46 11.189c.286.276.496.629.597 1.026l.003.015h12.911c.102-.413.313-.768.599-1.043l.001-.001L11.28 4.194zm.986 16.227 4.917-2.838a1.748 1.748 0 0 1-.038-.142H4.222l-.021.083 4.939 2.852c.39-.403.936-.653 1.54-.653.626 0 1.189.268 1.581.696l.001.002z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width=".88em" height="1em" viewBox="0 0 21 24"><path fill="currentColor" d="M12.731 2.751 17.666 5.6a2.138 2.138 0 1 1 2.07 3.548l-.015.003v5.7a2.14 2.14 0 1 1-2.098 3.502l-.002-.002-4.905 2.832a2.14 2.14 0 1 1-4.079.054l-.004.015-4.941-2.844a2.14 2.14 0 1 1-2.067-3.556l.015-.003V9.15a2.14 2.14 0 1 1 1.58-3.926l-.01-.005c.184.106.342.231.479.376l.001.001 4.938-2.85a2.14 2.14 0 1 1 4.096.021l.004-.015zm-.515.877a.766.766 0 0 1-.057.057l-.001.001 6.461 11.19c.026-.009.056-.016.082-.023V9.146a2.14 2.14 0 0 1-1.555-2.603l-.003.015.019-.072zm-3.015.059-.06-.06-4.946 2.852A2.137 2.137 0 0 1 2.749 9.12l-.015.004-.076.021v5.708l.084.023 6.461-11.19zm2.076.507a2.164 2.164 0 0 1-1.207-.004l.015.004-6.46 11.189c.286.276.496.629.597 1.026l.003.015h12.911c.102-.413.313-.768.599-1.043l.001-.001L11.28 4.194zm.986 16.227 4.917-2.838a1.748 1.748 0 0 1-.038-.142H4.222l-.021.083 4.939 2.852c.39-.403.936-.653 1.54-.653.626 0 1.189.268 1.581.696l.001.002z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1017 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M10.133 1h4.409a.5.5 0 0 1 .5.5v4.422c0 .026-.035.033-.045.01l-.048-.112a9.095 9.095 0 0 0-4.825-4.776c-.023-.01-.016-.044.01-.044Zm-8.588.275h-.5v1h.5c7.027 0 12.229 5.199 12.229 12.226v.5h1v-.5c0-7.58-5.65-13.226-13.229-13.226Zm.034 4.22h-.5v1h.5c2.361 0 4.348.837 5.744 2.238 1.395 1.401 2.227 3.395 2.227 5.758v.5h1v-.5c0-2.604-.921-4.859-2.52-6.463-1.596-1.605-3.845-2.532-6.45-2.532Zm-.528 8.996v-4.423c0-.041.033-.074.074-.074a4.923 4.923 0 0 1 4.923 4.922.074.074 0 0 1-.074.074H1.551a.5.5 0 0 1-.5-.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 684 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M9.277 2.084a.5.5 0 0 1 .185.607l-2.269 5.5a.5.5 0 0 1-.462.309H3.5a.5.5 0 0 1-.354-.854l5.5-5.5a.5.5 0 0 1 .631-.062ZM4.707 7.5h1.69l1.186-2.875L4.707 7.5Zm2.016 6.416a.5.5 0 0 1-.185-.607l2.269-5.5a.5.5 0 0 1 .462-.309H12.5a.5.5 0 0 1 .354.854l-5.5 5.5a.5.5 0 0 1-.631.062Zm4.57-5.416h-1.69l-1.186 2.875L11.293 8.5Z" clip-rule="evenodd"/><path fill="currentColor" fill-rule="evenodd" d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1 0A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 633 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 16 16"><path fill="currentColor" d="M1 2h4.257a2.5 2.5 0 0 1 1.768.732L9.293 5 5 9.293 3.732 8.025A2.5 2.5 0 0 1 3 6.257V4H2v2.257a3.5 3.5 0 0 0 1.025 2.475L5 10.707l1.25-1.25 2.396 2.397.708-.708L6.957 8.75 8.75 6.957l2.396 2.397.708-.708L9.457 6.25 10.707 5 7.732 2.025A3.5 3.5 0 0 0 5.257 1H1v1ZM10.646 2.354l2.622 2.62A2.5 2.5 0 0 1 14 6.744V12h1V6.743a3.5 3.5 0 0 0-1.025-2.475l-2.621-2.622-.707.708ZM4.268 13.975l-2.622-2.621.708-.708 2.62 2.622A2.5 2.5 0 0 0 6.744 14H15v1H6.743a3.5 3.5 0 0 1-2.475-1.025Z"/></svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -29,14 +29,6 @@
@apply antialiased;
accent-color: var(--accent-color);
font-variant-ligatures: common-ligatures;
// Colors
--info-color: #ec4899;
--success-color: #10b981;
--blue-color: #3b82f6;
--warning-color: #f59e0b;
--cl-error-color: #ef4444;
--sv-error-color: #dc2626;
}
::-webkit-scrollbar-track {
@@ -65,7 +57,7 @@ input::placeholder,
textarea::placeholder,
.cm-placeholder {
@apply text-secondary;
@apply opacity-50;
@apply opacity-50 #{!important};
}
input,
@@ -84,7 +76,7 @@ body {
@apply font-medium;
@apply select-none;
@apply overflow-x-hidden;
@apply leading-body;
@apply leading-body #{!important};
animation: fade 300ms forwards;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
@@ -166,7 +158,7 @@ a {
@apply shadow-none #{!important};
@apply fixed;
@apply inline-flex;
@apply -mt-8;
@apply -mt-7;
}
}
@@ -182,7 +174,7 @@ a {
@apply font-semibold;
@apply px-2 py-1;
@apply truncate;
@apply leading-normal;
@apply leading-body;
@apply items-center;
kbd {
@@ -229,7 +221,7 @@ a {
@apply overflow-y-auto;
@apply text-body text-secondary;
@apply p-2;
@apply leading-normal;
@apply leading-body;
@apply focus:outline-none;
scroll-behavior: smooth;
@@ -261,7 +253,7 @@ a {
hr {
@apply border-b border-dividerLight;
@apply my-2;
@apply my-2 #{!important};
}
.heading {
@@ -350,48 +342,33 @@ pre.ace_editor {
}
}
.select-wrapper {
@apply flex flex-1;
@apply relative;
@apply after:absolute;
@apply after:flex;
@apply after:inset-y-0;
@apply after:items-center;
@apply after:justify-center;
@apply after:pointer-events-none;
@apply after:font-icon;
@apply after:text-current;
@apply after:right-3;
@apply after:content-["\e5cf"];
@apply after:text-lg;
}
.info-response {
color: var(--info-color);
color: var(--status-info-color);
}
.success-response {
color: var(--success-color);
color: var(--status-success-color);
}
.redir-response {
color: var(--warning-color);
.redirect-response {
color: var(--status-redirect-color);
}
.cl-error-response {
color: var(--cl-error-color);
.critical-error-response {
color: var(--status-critical-error-color);
}
.sv-error-response {
color: var(--sv-error-color);
.server-error-response {
color: var(--status-server-error-color);
}
.missing-data-response {
@apply text-secondaryLight;
color: var(--status-missing-data-color);
}
.toasted-container {
@apply max-w-md;
@apply z-[10000];
.toasted {
&.toasted-primary {
@@ -537,12 +514,13 @@ pre.ace_editor {
@apply inline-flex;
@apply font-sans;
@apply text-tiny;
@apply bg-divider;
@apply bg-dividerLight;
@apply rounded;
@apply ml-2;
@apply px-1;
@apply min-w-5;
@apply min-h-5;
@apply px-0.5;
@apply min-w-[1rem];
@apply min-h-[1rem];
@apply leading-none;
@apply items-center;
@apply justify-center;
@apply border border-dividerDark;

View File

@@ -1,89 +1,89 @@
@mixin green-theme {
--accent-color: #10b981;
--accent-light-color: #34d399;
--accent-dark-color: #059669;
--accent-contrast-color: #fff;
--gradient-from-color: #a7f3d0;
--gradient-via-color: #34d399;
--gradient-to-color: #059669;
--accent-color: theme("colors.emerald.500");
--accent-light-color: theme("colors.emerald.400");
--accent-dark-color: theme("colors.emerald.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.emerald.400");
--gradient-via-color: theme("colors.emerald.500");
--gradient-to-color: theme("colors.emerald.600");
}
@mixin teal-theme {
--accent-color: #14b8a6;
--accent-light-color: #2dd4bf;
--accent-dark-color: #0d9488;
--accent-contrast-color: #fff;
--gradient-from-color: #99f6e4;
--gradient-via-color: #2dd4bf;
--gradient-to-color: #0d9488;
--accent-color: theme("colors.teal.500");
--accent-light-color: theme("colors.teal.400");
--accent-dark-color: theme("colors.teal.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.teal.400");
--gradient-via-color: theme("colors.teal.500");
--gradient-to-color: theme("colors.teal.600");
}
@mixin blue-theme {
--accent-color: #3b82f6;
--accent-light-color: #60a5fa;
--accent-dark-color: #2563eb;
--accent-contrast-color: #fff;
--gradient-from-color: #bfdbfe;
--gradient-via-color: #60a5fa;
--gradient-to-color: #2563eb;
--accent-color: theme("colors.blue.500");
--accent-light-color: theme("colors.blue.400");
--accent-dark-color: theme("colors.blue.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.blue.400");
--gradient-via-color: theme("colors.blue.500");
--gradient-to-color: theme("colors.blue.600");
}
@mixin indigo-theme {
--accent-color: #6366f1;
--accent-light-color: #818cf8;
--accent-dark-color: #4f46e5;
--accent-contrast-color: #fff;
--gradient-from-color: #c7d2fe;
--gradient-via-color: #818cf8;
--gradient-to-color: #4f46e5;
--accent-color: theme("colors.indigo.500");
--accent-light-color: theme("colors.indigo.400");
--accent-dark-color: theme("colors.indigo.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.indigo.400");
--gradient-via-color: theme("colors.indigo.500");
--gradient-to-color: theme("colors.indigo.600");
}
@mixin purple-theme {
--accent-color: #8b5cf6;
--accent-light-color: #a78bfa;
--accent-dark-color: #7c3aed;
--accent-contrast-color: #fff;
--gradient-from-color: #ddd6fe;
--gradient-via-color: #a78bfa;
--gradient-to-color: #7c3aed;
--accent-color: theme("colors.purple.500");
--accent-light-color: theme("colors.purple.400");
--accent-dark-color: theme("colors.purple.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.purple.400");
--gradient-via-color: theme("colors.purple.500");
--gradient-to-color: theme("colors.purple.600");
}
@mixin yellow-theme {
--accent-color: #f59e0b;
--accent-light-color: #fbbf24;
--accent-dark-color: #d97706;
--accent-contrast-color: #fff;
--gradient-from-color: #fde68a;
--gradient-via-color: #fbbf24;
--gradient-to-color: #d97706;
--accent-color: theme("colors.amber.500");
--accent-light-color: theme("colors.amber.400");
--accent-dark-color: theme("colors.amber.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.amber.400");
--gradient-via-color: theme("colors.amber.500");
--gradient-to-color: theme("colors.amber.600");
}
@mixin orange-theme {
--accent-color: #f97316;
--accent-light-color: #fb923c;
--accent-dark-color: #ea580c;
--accent-contrast-color: #fff;
--gradient-from-color: #fed7aa;
--gradient-via-color: #fb923c;
--gradient-to-color: #ea580c;
--accent-color: theme("colors.orange.500");
--accent-light-color: theme("colors.orange.400");
--accent-dark-color: theme("colors.orange.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.orange.400");
--gradient-via-color: theme("colors.orange.500");
--gradient-to-color: theme("colors.orange.600");
}
@mixin red-theme {
--accent-color: #ef4444;
--accent-light-color: #f87171;
--accent-dark-color: #dc2626;
--accent-contrast-color: #fff;
--gradient-from-color: #fecaca;
--gradient-via-color: #f87171;
--gradient-to-color: #dc2626;
--accent-color: theme("colors.red.500");
--accent-light-color: theme("colors.red.400");
--accent-dark-color: theme("colors.red.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.red.400");
--gradient-via-color: theme("colors.red.500");
--gradient-to-color: theme("colors.red.600");
}
@mixin pink-theme {
--accent-color: #ec4899;
--accent-light-color: #f472b6;
--accent-dark-color: #db2777;
--accent-contrast-color: #fff;
--gradient-from-color: #fbcfe8;
--gradient-via-color: #f472b6;
--gradient-to-color: #db2777;
--accent-color: theme("colors.pink.500");
--accent-light-color: theme("colors.pink.400");
--accent-dark-color: theme("colors.pink.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.pink.400");
--gradient-via-color: theme("colors.pink.500");
--gradient-to-color: theme("colors.pink.600");
}

View File

@@ -1,81 +1,141 @@
@mixin base-theme {
--font-sans: "Inter Variable", sans-serif;
--font-icon: "Material Symbols Rounded Variable";
--font-mono: "Roboto Mono Variable", monospace;
--font-size-body: 0.75rem;
--font-size-tiny: 0.688rem;
--font-size-tiny: 0.625rem;
--line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem;
--upper-tertiary-sticky-fold: 8.25rem;
--upper-fourth-sticky-fold: 10.2rem;
--upper-mobile-primary-sticky-fold: 6.625rem;
--upper-mobile-secondary-sticky-fold: 8.688rem;
--upper-mobile-sticky-fold: 10.75rem;
--upper-mobile-primary-sticky-fold: 6.75rem;
--upper-mobile-secondary-sticky-fold: 8.813rem;
--upper-mobile-sticky-fold: 10.875rem;
--upper-mobile-tertiary-sticky-fold: 8.25rem;
--lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5.063rem;
--lower-tertiary-sticky-fold: 7.125rem;
--lower-fourth-sticky-fold: 9.188rem;
--sidebar-primary-sticky-fold: 2rem;
--properties-primary-sticky-fold: 2.063rem;
}
@mixin light-theme {
--primary-color: theme("colors.white");
--primary-light-color: theme("colors.gray.50");
--primary-dark-color: theme("colors.gray.100");
--primary-contrast-color: #fdfdfd;
--secondary-color: theme("colors.gray.500");
--secondary-light-color: theme("colors.gray.400");
--secondary-dark-color: theme("colors.gray.900");
--divider-color: theme("colors.gray.100");
--divider-light-color: theme("colors.gray.100");
--divider-dark-color: theme("colors.gray.300");
--banner-info-color: theme("colors.stone.100");
--banner-warning-color: theme("colors.yellow.100");
--banner-error-color: theme("colors.red.100");
--tooltip-color: theme("colors.neutral.800");
--popover-color: theme("colors.white");
--method-get-color: theme("colors.green.500");
--method-post-color: theme("colors.amber.500");
--method-put-color: theme("colors.blue.500");
--method-patch-color: theme("colors.purple.500");
--method-delete-color: theme("colors.red.500");
--method-head-color: theme("colors.lime.500");
--method-options-color: theme("colors.pink.500");
--method-default-color: theme("colors.gray.500");
--status-info-color: theme("colors.blue.500");
--status-success-color: theme("colors.green.500");
--status-redirect-color: theme("colors.amber.500");
--status-critical-error-color: theme("colors.red.500");
--status-server-error-color: theme("colors.rose.500");
--status-missing-data-color: theme("colors.slate.500");
--editor-theme: "textmate";
}
@mixin dark-theme {
--primary-color: #181818;
--primary-light-color: #1c1c1e;
--primary-dark-color: #262626;
--primary-contrast-color: #171717;
--primary-dark-color: theme("colors.neutral.800");
--primary-contrast-color: theme("colors.neutral.900");
--secondary-color: #a3a3a3;
--secondary-light-color: #737373;
--secondary-dark-color: #fafafa;
--secondary-color: theme("colors.neutral.400");
--secondary-light-color: theme("colors.neutral.500");
--secondary-dark-color: theme("colors.zinc.50");
--divider-color: #262626;
--divider-color: #1f1f1f;
--divider-light-color: #1f1f1f;
--divider-dark-color: #2d2d2d;
--divider-dark-color: theme("colors.zinc.800");
--error-color: #292524;
--tooltip-color: #f5f5f5;
--banner-info-color: theme("colors.stone.800");
--banner-warning-color: theme("colors.yellow.800");
--banner-error-color: theme("colors.red.800");
--tooltip-color: theme("colors.neutral.100");
--popover-color: #1b1b1b;
--method-get-color: theme("colors.emerald.500");
--method-post-color: theme("colors.yellow.500");
--method-put-color: theme("colors.sky.500");
--method-patch-color: theme("colors.violet.500");
--method-delete-color: theme("colors.rose.500");
--method-head-color: theme("colors.teal.500");
--method-options-color: theme("colors.indigo.500");
--method-default-color: theme("colors.neutral.500");
--status-info-color: theme("colors.blue.500");
--status-success-color: theme("colors.green.500");
--status-redirect-color: theme("colors.amber.500");
--status-critical-error-color: theme("colors.red.500");
--status-server-error-color: theme("colors.rose.500");
--status-missing-data-color: theme("colors.slate.500");
--editor-theme: "merbivore_soft";
}
@mixin light-theme {
--primary-color: #ffffff;
--primary-light-color: #f9fafb;
--primary-dark-color: #f3f4f6;
--primary-contrast-color: #fdfdfd;
--secondary-color: #6b7280;
--secondary-light-color: #9ca3af;
--secondary-dark-color: #111827;
--divider-color: #f3f4f6;
--divider-light-color: #f3f4f6;
--divider-dark-color: #d1d5db;
--error-color: #fef3c7;
--tooltip-color: #262626;
--popover-color: #ffffff;
--editor-theme: "textmate";
}
@mixin black-theme {
--primary-color: #0f0f0f;
--primary-light-color: #171717;
--primary-light-color: theme("colors.neutral.900");
--primary-dark-color: #181818;
--primary-contrast-color: #0f0f0f;
--secondary-color: #a3a3a3;
--secondary-light-color: #737373;
--secondary-dark-color: #f5f5f5;
--secondary-color: theme("colors.neutral.400");
--secondary-light-color: theme("colors.neutral.500");
--secondary-dark-color: theme("colors.neutral.50");
--divider-color: #1c1c1e;
--divider-light-color: #181818;
--divider-dark-color: #323232;
--divider-color: theme("colors.neutral.900");
--divider-light-color: theme("colors.neutral.900");
--divider-dark-color: theme("colors.zinc.800");
--banner-info-color: theme("colors.stone.900");
--banner-warning-color: theme("colors.yellow.900");
--banner-error-color: theme("colors.red.900");
--tooltip-color: theme("colors.neutral.100");
--popover-color: theme("colors.stone.950");
--method-get-color: theme("colors.emerald.500");
--method-post-color: theme("colors.yellow.500");
--method-put-color: theme("colors.sky.500");
--method-patch-color: theme("colors.violet.500");
--method-delete-color: theme("colors.rose.500");
--method-head-color: theme("colors.teal.500");
--method-options-color: theme("colors.indigo.500");
--method-default-color: theme("colors.zinc.500");
--status-info-color: theme("colors.blue.500");
--status-success-color: theme("colors.green.500");
--status-redirect-color: theme("colors.amber.500");
--status-critical-error-color: theme("colors.red.500");
--status-server-error-color: theme("colors.rose.500");
--status-missing-data-color: theme("colors.slate.500");
--error-color: #1c1917;
--tooltip-color: #f5f5f5;
--popover-color: #0f0f0f;
--editor-theme: "twilight";
}

View File

@@ -1,41 +1,41 @@
@mixin dark-editor-theme {
--editor-type-color: #a78bfa;
--editor-name-color: #60a5fa;
--editor-operator-color: #818cf8;
--editor-invalid-color: #f87171;
--editor-separator-color: #9ca3af;
--editor-meta-color: #9ca3af;
--editor-variable-color: #34d399;
--editor-link-color: #22d3ee;
--editor-process-color: #e879f9;
--editor-constant-color: #a78bfa;
--editor-keyword-color: #f472b6;
@mixin light-editor-theme {
--editor-type-color: theme("colors.violet.600");
--editor-name-color: theme("colors.red.600");
--editor-operator-color: theme("colors.indigo.600");
--editor-invalid-color: theme("colors.red.600");
--editor-separator-color: theme("colors.gray.600");
--editor-meta-color: theme("colors.gray.600");
--editor-variable-color: theme("colors.emerald.600");
--editor-link-color: theme("colors.cyan.600");
--editor-process-color: theme("colors.blue.600");
--editor-constant-color: theme("colors.fuchsia.600");
--editor-keyword-color: theme("colors.pink.600");
}
@mixin light-editor-theme {
--editor-type-color: #7c3aed;
--editor-name-color: #dc2626;
--editor-operator-color: #4f46e5;
--editor-invalid-color: #dc2626;
--editor-separator-color: #4b5563;
--editor-meta-color: #4b5563;
--editor-variable-color: #059669;
--editor-link-color: #0891b2;
--editor-process-color: #2563eb;
--editor-constant-color: #c026d3;
--editor-keyword-color: #db2777;
@mixin dark-editor-theme {
--editor-type-color: theme("colors.violet.400");
--editor-name-color: theme("colors.blue.400");
--editor-operator-color: theme("colors.indigo.400");
--editor-invalid-color: theme("colors.red.400");
--editor-separator-color: theme("colors.gray.400");
--editor-meta-color: theme("colors.gray.400");
--editor-variable-color: theme("colors.emerald.400");
--editor-link-color: theme("colors.cyan.400");
--editor-process-color: theme("colors.fuchsia.400");
--editor-constant-color: theme("colors.violet.400");
--editor-keyword-color: theme("colors.pink.400");
}
@mixin black-editor-theme {
--editor-type-color: #a78bfa;
--editor-name-color: #e879f9;
--editor-operator-color: #818cf8;
--editor-invalid-color: #f87171;
--editor-separator-color: #9ca3af;
--editor-meta-color: #9ca3af;
--editor-variable-color: #34d399;
--editor-link-color: #22d3ee;
--editor-process-color: #a78bfa;
--editor-constant-color: #60a5fa;
--editor-keyword-color: #f472b6;
--editor-type-color: theme("colors.violet.400");
--editor-name-color: theme("colors.fuchsia.400");
--editor-operator-color: theme("colors.indigo.400");
--editor-invalid-color: theme("colors.red.400");
--editor-separator-color: theme("colors.gray.400");
--editor-meta-color: theme("colors.gray.400");
--editor-variable-color: theme("colors.emerald.400");
--editor-link-color: theme("colors.cyan.400");
--editor-process-color: theme("colors.violet.400");
--editor-constant-color: theme("colors.blue.400");
--editor-keyword-color: theme("colors.pink.400");
}

View File

@@ -11,6 +11,7 @@
"connect": "Connect",
"connecting": "Connecting",
"copy": "Copy",
"create": "Create",
"delete": "Delete",
"disconnect": "Disconnect",
"dismiss": "Dismiss",
@@ -32,6 +33,7 @@
"open_workspace": "Open workspace",
"paste": "Paste",
"prettify": "Prettify",
"properties":"Properties",
"remove": "Remove",
"rename": "Rename",
"restore": "Restore",
@@ -40,6 +42,7 @@
"scroll_to_top": "Scroll to top",
"search": "Search",
"send": "Send",
"share": "Share",
"start": "Start",
"starting": "Starting",
"stop": "Stop",
@@ -78,6 +81,7 @@
"contact_us": "Contact us",
"cookies": "Cookies",
"copy": "Copy",
"copy_interface_type": "Copy interface type",
"copy_user_id": "Copy User Auth Token",
"developer_option": "Developer options",
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.",
@@ -93,6 +97,7 @@
"keyboard_shortcuts": "Keyboard shortcuts",
"name": "Hoppscotch",
"new_version_found": "New version found. Refresh to update.",
"open_in_hoppscotch": "Open in Hoppscotch",
"options": "Options",
"proxy_privacy_policy": "Proxy privacy policy",
"reload": "Reload",
@@ -134,9 +139,11 @@
"generate_token": "Generate Token",
"graphql_headers": "Authorization Headers are sent as part of the payload to connection_init",
"include_in_url": "Include in URL",
"inherited_from": "Inherited from {auth} from Parent Collection {collection} ",
"learn": "Learn how",
"pass_key_by": "Pass by",
"password": "Password",
"save_to_inherit": "Please save this request in any collection to inherit the authorization",
"token": "Token",
"type": "Authorization Type",
"username": "Username",
@@ -168,6 +175,8 @@
"name_length_insufficient": "Collection name should be at least 3 characters long",
"new": "New Collection",
"order_changed": "Collection Order Updated",
"properties":"Collection Properties",
"properties_updated": "Collection Properties Updated",
"renamed": "Collection renamed",
"request_in_use": "Request in use",
"save_as": "Save as",
@@ -187,6 +196,7 @@
"remove_folder": "Are you sure you want to permanently delete this folder?",
"remove_history": "Are you sure you want to permanently delete all history?",
"remove_request": "Are you sure you want to permanently delete this request?",
"remove_shared_request": "Are you sure you want to permanently delete this shared request?",
"remove_team": "Are you sure you want to delete this team?",
"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.",
@@ -228,7 +238,8 @@
"profile": "Login to view your profile",
"protocols": "Protocols are empty",
"schema": "Connect to a GraphQL endpoint to view schema",
"shortcodes": "Shortcodes are empty",
"shared_requests_logout": "Login to view your shared requests or create a new one",
"shared_requests": "Shared requests are empty",
"subscription": "Subscriptions are empty",
"team_name": "Team name empty",
"teams": "You don't belong to any teams",
@@ -268,6 +279,9 @@
"variable": "Variable",
"variable_list": "Variable List"
},
"graphql_collections": {
"title": "GraphQL Collections"
},
"error": {
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.",
@@ -296,14 +310,16 @@
"proxy_error": "Proxy error",
"script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong",
"test_script_fail": "Could not execute post-request script"
"test_script_fail": "Could not execute post-request script",
"authproviders_load_error": "Unable to load auth providers"
},
"export": {
"as_json": "Export as JSON",
"create_secret_gist": "Create secret Gist",
"gist_created": "Gist created",
"require_github": "Login with GitHub to create secret gist",
"title": "Export"
"title": "Export",
"failed": "Something went wrong while exporting"
},
"filter": {
"all": "All",
@@ -340,10 +356,12 @@
"authorization": "The authorization header will be automatically generated when you send the request.",
"generate_documentation_first": "Generate documentation first",
"network_fail": "Unable to reach the API endpoint. Check your network connection or select a different Interceptor and try again.",
"offline": "You seem to be offline. Data in this workspace might not be up to date.",
"offline_short": "You seem to be offline.",
"offline": "You're using Hoppscotch offline. Updates will sync when you're online, based on workspace settings.",
"offline_short": "You're using Hoppscotch offline.",
"post_request_tests": "Test scripts are written in JavaScript, and are run after the response is received.",
"pre_request_script": "Pre-request scripts are written in JavaScript, and are run before the request is sent.",
"collection_properties_authorization": " This authorization will be set for every request in this collection.",
"collection_properties_header": "This header will be set for every request in this collection.",
"script_fail": "It seems there is a glitch in the pre-request script. Check the error below and fix the script accordingly.",
"test_script_fail": "There seems to be an error with test script. Please fix the errors and run tests again",
"tests": "Write a test script to automate debugging."
@@ -370,6 +388,7 @@
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)",
"from_postman": "Import from Postman",
"from_postman_description": "Import from Postman collection",
"from_file": "Import from File",
"from_url": "Import from URL",
"gist_url": "Enter Gist URL",
"import_from_url_invalid_fetch": "Couldn't get data from the url",
@@ -377,7 +396,15 @@
"import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'",
"import_from_url_success": "Collections Imported",
"json_description": "Import collections from a Hoppscotch Collections JSON file",
"title": "Import"
"title": "Import",
"hoppscotch_environment": "Hoppscotch Environment",
"hoppscotch_environment_description": "Import Hoppscotch Environment JSON file",
"postman_environment": "Postman Environment",
"postman_environment_description": "Import Postman Environment from a JSON file",
"insomnia_environment_description": "Import Insomnia Environment from a JSON/YAML file",
"environments_from_gist": "Import From Gist",
"environments_from_gist_description": "Import Hoppscotch Environments From Gist",
"gql_collections_from_gist_description": "Import GraphQL Collections From Gist"
},
"inspections": {
"description": "Inspect possible errors",
@@ -414,7 +441,9 @@
"close_unsaved_tab": "You have unsaved changes",
"collections": "Collections",
"confirm": "Confirm",
"customize_request": "Customize Request",
"edit_request": "Edit Request",
"share_request": "Share Request",
"import_export": "Import / Export"
},
"mqtt": {
@@ -490,7 +519,6 @@
"structured": "Structured",
"text": "Text"
},
"copy_link": "Copy link",
"different_collection": "Cannot reorder requests from different collections",
"duplicated": "Request duplicated",
"duration": "Duration",
@@ -523,6 +551,7 @@
"saved": "Request saved",
"share": "Share",
"share_description": "Share Hoppscotch with your friends",
"share_request": "Share Request",
"stop": "Stop",
"title": "Request",
"type": "Request type",
@@ -603,14 +632,31 @@
"additional": "Additional Settings",
"verify_email": "Verify email"
},
"shortcodes": {
"actions": "Actions",
"created_on": "Created on",
"deleted": "Shortcode deleted",
"method": "Method",
"not_found": "Shortcode not found",
"short_code": "Short code",
"url": "URL"
"shared_requests": {
"button": "Button",
"button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.",
"customize": "Customize",
"creating_widget": "Creating widget",
"copy_html": "Copy HTML",
"copy_link": "Copy Link",
"copy_markdown": "Copy Markdown",
"deleted": "Shared request deleted",
"description": "Select a widget, you can change and customize this later",
"embed": "Embed",
"embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.",
"link": "Link",
"link_info": "Create a shareable link to share with anyone on the internet with view access.",
"modified": "Shared request modified",
"not_found": "Shared request not found",
"open_new_tab": "Open in new tab",
"preview": "Preview",
"run_in_hoppscotch": "Run in Hoppscotch",
"theme": {
"dark": "Dark",
"light": "Light",
"system": "System",
"title": "Theme"
}
},
"shortcut": {
"general": {
@@ -640,7 +686,6 @@
"title": "Others"
},
"request": {
"copy_request_link": "Copy Request Link",
"delete_method": "Select DELETE method",
"get_method": "Select GET method",
"head_method": "Select HEAD method",
@@ -656,6 +701,7 @@
"save_to_collections": "Save to Collections",
"send_request": "Send Request",
"show_code": "Generate code snippet",
"share_request": "Share Request",
"title": "Request"
},
"response": {
@@ -780,6 +826,7 @@
"connection_failed": "Connection failed",
"connection_lost": "Connection lost",
"copied_to_clipboard": "Copied to clipboard",
"copied_interface_to_clipboard": "Copied {language} interface type to clipboard",
"deleted": "Deleted",
"deprecated": "DEPRECATED",
"disabled": "Disabled",
@@ -838,6 +885,7 @@
"queries": "Queries",
"query": "Query",
"schema": "Schema",
"shared_requests": "Shared Requests",
"socketio": "Socket.IO",
"sse": "SSE",
"tests": "Tests",

View File

@@ -22,45 +22,41 @@
},
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@codemirror/autocomplete": "^6.10.2",
"@codemirror/autocomplete": "^6.11.0",
"@codemirror/commands": "^6.3.0",
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "6.9.0",
"@codemirror/language": "6.9.2",
"@codemirror/legacy-modes": "^6.3.3",
"@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.4",
"@codemirror/state": "^6.3.1",
"@codemirror/view": "^6.22.0",
"@fontsource-variable/inter": "^5.0.8",
"@fontsource-variable/material-symbols-rounded": "^5.0.7",
"@fontsource-variable/roboto-mono": "^5.0.9",
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
"@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/ui": "workspace:^",
"@hoppscotch/vue-toasted": "^0.1.0",
"@lezer/highlight": "1.1.4",
"@urql/core": "^4.1.1",
"@lezer/highlight": "1.2.0",
"@unhead/vue": "^1.8.8",
"@urql/core": "^4.2.0",
"@urql/devtools": "^2.0.3",
"@urql/exchange-auth": "^2.1.6",
"@urql/exchange-graphcache": "^6.3.2",
"@urql/exchange-graphcache": "^6.3.3",
"@vitejs/plugin-legacy": "^4.1.1",
"@vueuse/core": "^10.3.0",
"@vueuse/head": "^1.3.1",
"acorn-walk": "^8.2.0",
"axios": "^1.4.0",
"@vueuse/core": "^10.6.1",
"acorn-walk": "^8.3.0",
"axios": "^1.6.2",
"buffer": "^6.0.3",
"cookie-es": "^1.0.0",
"dioc": "workspace:^",
"dioc": "^1.0.1",
"esprima": "^4.0.1",
"events": "^3.3.0",
"fp-ts": "^2.16.1",
"fuse.js": "^6.6.2",
"globalthis": "^1.0.3",
"graphql": "^16.8.0",
"graphql-language-service-interface": "^2.9.1",
"graphql": "^16.8.1",
"graphql-language-service-interface": "^2.10.2",
"graphql-tag": "^2.12.6",
"httpsnippet": "^3.0.1",
"insomnia-importers": "^3.6.0",
@@ -68,14 +64,15 @@
"js-yaml": "^4.1.0",
"jsonpath-plus": "^7.2.0",
"lodash-es": "^4.17.21",
"lossless-json": "^2.0.11",
"minisearch": "^6.1.0",
"lossless-json": "^3.0.2",
"minisearch": "^6.3.0",
"nprogress": "^0.2.0",
"paho-mqtt": "^1.1.0",
"path": "^0.12.7",
"postman-collection": "^4.2.0",
"postman-collection": "^4.3.0",
"process": "^0.11.10",
"qs": "^6.11.2",
"quicktype-core": "^23.0.79",
"rxjs": "^7.8.1",
"set-cookie-parser": "^2.6.0",
"set-cookie-parser-es": "^1.0.5",
@@ -89,19 +86,19 @@
"tern": "^0.24.3",
"timers": "^0.1.1",
"tippy.js": "^6.3.7",
"url": "^0.11.1",
"url": "^0.11.3",
"util": "^0.12.5",
"uuid": "^9.0.0",
"verzod": "^0.2.0",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-pdf-embed": "^1.1.6",
"vue-router": "^4.2.4",
"uuid": "^9.0.1",
"vue": "^3.3.8",
"vue-i18n": "^9.7.1",
"vue-pdf-embed": "^1.2.1",
"vue-router": "^4.2.5",
"vue-tippy": "6.3.1",
"vuedraggable-es": "^4.1.1",
"wonka": "^6.3.4",
"workbox-window": "^7.0.0",
"xml-formatter": "^3.5.0",
"xml-formatter": "^3.6.0",
"yargs-parser": "^21.1.1",
"zod": "^3.22.4"
},
@@ -113,57 +110,58 @@
"@graphql-codegen/typed-document-node": "^5.0.1",
"@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-operations": "^4.0.1",
"@graphql-codegen/typescript-urql-graphcache": "^2.4.5",
"@graphql-codegen/urql-introspection": "^2.2.1",
"@graphql-codegen/typescript-urql-graphcache": "^3.0.0",
"@graphql-codegen/urql-introspection": "^3.0.0",
"@graphql-typed-document-node/core": "^3.2.0",
"@iconify-json/lucide": "^1.1.119",
"@iconify-json/lucide": "^1.1.141",
"@intlify/vite-plugin-vue-i18n": "^7.0.0",
"@relmify/jest-fp-ts": "^2.1.1",
"@rushstack/eslint-patch": "^1.3.3",
"@types/har-format": "^1.2.12",
"@types/js-yaml": "^4.0.5",
"@types/lodash-es": "^4.17.8",
"@types/lossless-json": "^1.0.1",
"@types/nprogress": "^0.2.0",
"@types/paho-mqtt": "^1.0.7",
"@types/postman-collection": "^3.5.7",
"@types/splitpanes": "^2.2.1",
"@types/uuid": "^9.0.2",
"@types/yargs-parser": "^21.0.0",
"@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^6.4.0",
"@vitejs/plugin-vue": "^4.3.1",
"@vue/compiler-sfc": "^3.3.4",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/runtime-core": "^3.3.4",
"@rushstack/eslint-patch": "^1.6.0",
"@types/har-format": "^1.2.15",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/lossless-json": "^1.0.4",
"@types/nprogress": "^0.2.3",
"@types/paho-mqtt": "^1.0.10",
"@types/postman-collection": "^3.5.10",
"@types/splitpanes": "^2.2.6",
"@types/uuid": "^9.0.7",
"@types/yargs-parser": "^21.0.3",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"@vitejs/plugin-vue": "^4.5.0",
"@vue/compiler-sfc": "^3.3.8",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/runtime-core": "^3.3.8",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3",
"dotenv": "^16.3.1",
"eslint": "^8.47.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.17.0",
"eslint": "^8.54.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-vue": "^9.18.1",
"glob": "^10.3.10",
"npm-run-all": "^4.1.5",
"openapi-types": "^12.1.3",
"postcss": "^8.4.23",
"prettier-plugin-tailwindcss": "^0.5.6",
"rollup-plugin-polyfill-node": "^0.12.0",
"sass": "^1.66.0",
"prettier": "^3.1.0",
"prettier-plugin-tailwindcss": "^0.5.7",
"rollup-plugin-polyfill-node": "^0.13.0",
"sass": "^1.69.5",
"tailwindcss": "^3.3.2",
"typescript": "^5.1.6",
"unplugin-fonts": "^1.0.3",
"unplugin-icons": "^0.16.5",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.4.9",
"vite-plugin-checker": "^0.6.1",
"vite-plugin-fonts": "^0.6.0",
"typescript": "^5.3.2",
"unplugin-fonts": "^1.1.1",
"unplugin-icons": "^0.17.4",
"unplugin-vue-components": "^0.25.2",
"vite": "^4.5.0",
"vite-plugin-checker": "^0.6.2",
"vite-plugin-fonts": "^0.7.0",
"vite-plugin-html-config": "^1.0.11",
"vite-plugin-inspect": "^0.7.38",
"vite-plugin-inspect": "^0.7.42",
"vite-plugin-pages": "^0.31.0",
"vite-plugin-pages-sitemap": "^1.6.1",
"vite-plugin-pwa": "^0.16.4",
"vite-plugin-pwa": "^0.17.0",
"vite-plugin-vue-layouts": "^0.8.0",
"vitest": "^0.34.2",
"vue-tsc": "^1.8.8"
"vitest": "^0.34.6",
"vue-tsc": "^1.8.22"
}
}

View File

@@ -56,11 +56,13 @@ declare module 'vue' {
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']
CollectionsProperties: typeof import('./components/collections/Properties.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']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default']
Embeds: typeof import('./components/embeds/index.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
@@ -108,6 +110,7 @@ declare module 'vue' {
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
@@ -160,6 +163,14 @@ declare module 'vue' {
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
ImportExportImportExportSourcesList: typeof import('./components/importExport/ImportExportSourcesList.vue')['default']
ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default']
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
@@ -183,6 +194,16 @@ declare module 'vue' {
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
Share: typeof import('./components/share/index.vue')['default']
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
ShareCustomizeModal: typeof import('./components/share/CustomizeModal.vue')['default']
ShareModal: typeof import('./components/share/Modal.vue')['default']
ShareRequest: typeof import('./components/share/Request.vue')['default']
ShareRequestModal: typeof import('./components/share/RequestModal.vue')['default']
ShareShareRequestModal: typeof import('./components/share/ShareRequestModal.vue')['default']
ShareTemplatesButton: typeof import('./components/share/templates/Button.vue')['default']
ShareTemplatesEmbeds: typeof import('./components/share/templates/Embeds.vue')['default']
ShareTemplatesLink: typeof import('./components/share/templates/Link.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']
@@ -203,6 +224,7 @@ declare module 'vue' {
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']
SmartSelectWrapper: typeof import('./../../hoppscotch-ui/src/components/smart/SelectWrapper.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']

View File

@@ -1,7 +1,7 @@
<template>
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
<AppShare :show="showShare" @hide-modal="showShare = false" />
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
<FirebaseLogin v-if="showLogin" @hide-modal="showLogin = false" />
</template>
<script setup lang="ts">

View File

@@ -1,19 +1,23 @@
<template>
<div
:role="bannerRole"
class="flex items-center px-4 py-2 text-tiny"
class="flex items-center justify-between px-4 py-2 text-tiny text-secondaryDark"
:class="bannerColor"
>
<component :is="bannerIcon" class="mr-2 text-white" />
<span class="text-white">
<span v-if="banner.alternateText" class="md:hidden">
{{ banner.alternateText(t) }}
</span>
<span :class="banner.alternateText ? '<md:hidden' : ''">
<div class="flex items-center">
<component :is="bannerIcon" class="mr-2" />
<span :class="{ 'hidden sm:inline-flex': banner.alternateText }">
{{ banner.text(t) }}
</span>
</span>
<span v-if="banner.alternateText" class="inline-flex sm:hidden">
{{ banner.alternateText(t) }}
</span>
</div>
<icon-lucide-x
v-if="dismissible"
class="opacity-50 hover:cursor-pointer hover:opacity-100"
@click="emit('dismiss')"
/>
</div>
</template>
@@ -26,22 +30,32 @@ import IconAlertCircle from "~icons/lucide/alert-circle"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import IconInfo from "~icons/lucide/info"
const props = defineProps<{
banner: BannerContent
}>()
const props = withDefaults(
defineProps<{
banner: BannerContent
dismissible?: boolean
}>(),
{
dismissible: false,
}
)
const t = useI18n()
const emit = defineEmits<{
(e: "dismiss"): void
}>()
const ariaRoles: Record<BannerType, string> = {
error: "alert",
warning: "status",
info: "status",
warning: "status",
error: "alert",
}
const bgColors: Record<BannerType, string> = {
error: "bg-red-700",
warning: "bg-yellow-700",
info: "bg-stone-800",
info: "bg-bannerInfo",
warning: "bg-bannerWarning",
error: "bg-bannerError",
}
const icons = {

View File

@@ -2,25 +2,27 @@
<div>
<header
ref="headerRef"
class="flex flex-1 flex-shrink-0 items-center justify-between space-x-2 overflow-x-auto overflow-y-hidden px-2 py-2"
class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
>
<div
class="inline-flex flex-1 items-center justify-start space-x-2"
class="col-span-2 flex items-center justify-between space-x-2"
:style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
}"
>
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
<div class="flex">
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
</div>
</div>
<div class="inline-flex flex-1 items-center justify-center space-x-2">
<div class="col-span-1 flex items-center justify-between space-x-2">
<button
class="flex max-w-[15rem] flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 py-1 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
class="flex h-full flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 text-secondaryLight transition 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">
@@ -32,192 +34,189 @@
<kbd class="shortcut-key">K</kbd>
</span>
</button>
<HoppButtonSecondary
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
:title="t('header.install_pwa')"
:icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="installPWA()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconLifeBuoy"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div class="inline-flex flex-1 items-center justify-end space-x-2">
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<div class="col-span-2 flex items-center justify-between space-x-2">
<div class="flex">
<HoppButtonSecondary
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 hidden border border-green-600/25 bg-green-500/[.15] !text-green-500 hover:border-green-800/50 hover:bg-green-400/10 focus-visible:border-green-800/50 focus-visible:bg-green-400/10 md:flex"
@click="invokeAction('modals.login.toggle')"
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
:title="t('header.install_pwa')"
:icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="installPWA()"
/>
<HoppButtonPrimary
:label="t('header.login')"
@click="invokeAction('modals.login.toggle')"
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconLifeBuoy"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div v-else class="inline-flex items-center space-x-2">
<TeamsMemberStack
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam.teamMembers.length > 1
"
:team-members="selectedTeam.teamMembers"
show-count
class="mx-2"
@handle-click="handleTeamEdit()"
/>
<div class="flex">
<div
class="flex divide-x divide-green-600/25 rounded border border-green-600/25 bg-green-500/[.15] focus-within:divide-green-800/50 focus-within:border-green-800/50 focus-within:bg-green-400/10 hover:divide-green-800/50 hover:border-green-800/50 hover:bg-green-400/10"
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleInvite()"
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 hidden h-8 border border-emerald-600/25 bg-emerald-500/10 !text-emerald-500 hover:border-emerald-600/20 hover:bg-emerald-600/20 focus-visible:border-emerald-600/20 focus-visible:bg-emerald-600/20 md:flex"
@click="invokeAction('modals.login.toggle')"
/>
<HoppButtonSecondary
<HoppButtonPrimary
:label="t('header.login')"
class="h-8"
@click="invokeAction('modals.login.toggle')"
/>
</div>
<div v-else class="inline-flex items-center space-x-2">
<TeamsMemberStack
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
selectedTeam.teamMembers.length > 1
"
v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')"
:icon="IconSettings"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleTeamEdit()"
:team-members="selectedTeam.teamMembers"
show-count
class="mx-2"
@handle-click="handleTeamEdit()"
/>
</div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => accountActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="select-wrapper !focus-visible:text-blue-600 !hover:text-blue-600 rounded border border-blue-600/25 bg-blue-500/[.15] py-[0.4375rem] pr-8 !text-blue-500 hover:border-blue-800/50 hover:bg-blue-400/10 focus-visible:border-blue-800/50 focus-visible:bg-blue-400/10"
/>
<template #content="{ hide }">
<div
ref="accountActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
@click="hide()"
>
<WorkspaceSelector />
</div>
</template>
</tippy>
<span class="px-2">
<div
class="flex h-8 divide-x divide-emerald-600/25 rounded border border-emerald-600/25 bg-emerald-500/10 focus-within:divide-emerald-600/20 focus-within:border-emerald-600/20 focus-within:bg-emerald-600/20 hover:divide-emerald-600/20 hover:border-emerald-600/20 hover:bg-emerald-600/20"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleInvite()"
/>
<HoppButtonSecondary
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
"
v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')"
:icon="IconSettings"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleTeamEdit()"
/>
</div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
:on-shown="() => accountActions.focus()"
>
<HoppSmartPicture
v-if="currentUser.photoURL"
v-tippy="{
theme: 'tooltip',
}"
:url="currentUser.photoURL"
:alt="
currentUser.displayName ||
t('profile.default_hopp_displayname')
"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<HoppSmartPicture
v-else
v-tippy="{ theme: 'tooltip' }"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
:initial="currentUser.displayName || currentUser.email"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<HoppSmartSelectWrapper
class="!text-blue-500 !focus-visible:text-blue-600 !hover:text-blue-600"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="!focus-visible:text-blue-600 !hover:text-blue-600 h-8 rounded border border-blue-600/25 bg-blue-500/10 pr-8 !text-blue-500 hover:border-blue-600/20 hover:bg-blue-600/20 focus-visible:border-blue-600/20 focus-visible:bg-blue-600/20"
/>
</HoppSmartSelectWrapper>
<template #content="{ hide }">
<div
ref="tippyActions"
ref="accountActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.p="profile.$el.click()"
@keyup.s="settings.$el.click()"
@keyup.l="logout.$el.click()"
@keyup.escape="hide()"
@click="hide()"
>
<div class="flex flex-col px-2 text-tiny">
<span class="inline-flex truncate font-semibold">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</span>
<span class="inline-flex truncate text-secondaryLight">
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
ref="profile"
to="/profile"
:icon="IconUser"
:label="t('navigation.profile')"
:shortcut="['P']"
@click="hide()"
/>
<HoppSmartItem
ref="settings"
to="/settings"
:icon="IconSettings"
:label="t('navigation.settings')"
:shortcut="['S']"
@click="hide()"
/>
<FirebaseLogout
ref="logout"
:shortcut="['L']"
@confirm-logout="hide()"
/>
<WorkspaceSelector />
</div>
</template>
</tippy>
</span>
<span class="px-2">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<HoppSmartPicture
v-tippy="{
theme: 'tooltip',
}"
:name="currentUser.uid"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.p="profile.$el.click()"
@keyup.s="settings.$el.click()"
@keyup.l="logout.$el.click()"
@keyup.escape="hide()"
>
<div class="flex flex-col px-2">
<span class="inline-flex truncate font-semibold">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</span>
<span
class="inline-flex truncate text-secondaryLight text-tiny"
>
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
ref="profile"
to="/profile"
:icon="IconUser"
:label="t('navigation.profile')"
:shortcut="['P']"
@click="hide()"
/>
<HoppSmartItem
ref="settings"
to="/settings"
:icon="IconSettings"
:label="t('navigation.settings')"
:shortcut="['S']"
@click="hide()"
/>
<FirebaseLogout
ref="logout"
:shortcut="['L']"
@confirm-logout="hide()"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</header>
<AppBanner v-if="bannerContent" :banner="bannerContent" />
<AppBanner
v-if="bannerContent"
:banner="bannerContent"
:dismissible="true"
@dismiss="dismissOfflineBanner"
/>
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
<TeamsInvite
v-if="workspace.type === 'team' && workspace.teamID"
@@ -233,7 +232,6 @@
@invite-team="inviteTeam(editingTeamName, editingTeamID)"
@refetch-teams="refetchTeams"
/>
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@@ -293,7 +291,7 @@ const bannerContent = computed(() => banner.content.value?.content)
let bannerID: number | null = null
const offlineBanner: BannerContent = {
type: "info",
type: "warning",
text: (t) => t("helpers.offline"),
alternateText: (t) => t("helpers.offline_short"),
score: BANNER_PRIORITY_HIGH,
@@ -307,13 +305,14 @@ watch(isOnline, () => {
if (!isOnline.value) {
bannerID = banner.showBanner(offlineBanner)
return
} else {
if (banner.content && bannerID) {
banner.removeBanner(bannerID)
}
}
if (banner.content && bannerID) {
banner.removeBanner(bannerID)
}
})
const dismissOfflineBanner = () => banner.removeBanner(bannerID!)
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()

View File

@@ -92,9 +92,8 @@ const getHighestSeverity = computed(() => {
},
{ severity: 0 }
)
} else {
return { severity: 0 }
}
return { severity: 0 }
})
const severityColor = (severity: number) => {

View File

@@ -17,9 +17,10 @@
v-if="isEmpty(shortcutsResults)"
:text="`${t('state.nothing_found')} ‟${filterText}”`"
>
<icon-lucide-search class="svg-icons pb-2 opacity-75" />
<template #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
</HoppSmartPlaceholder>
<details
v-for="(sectionResults, sectionTitle) in shortcutsResults"
v-else

View File

@@ -9,8 +9,8 @@
>
<component
:is="entry.icon"
class="svg-icons opacity-50"
:class="{ 'opacity-100': active }"
class="svg-icons opacity-80"
:class="{ 'opacity-25': active }"
/>
<template
v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'"
@@ -82,9 +82,9 @@ const props = defineProps<{
const formattedShortcutKeys = computed(
() =>
props.entry.meta?.keyboardShortcut?.map((key) => {
return SPECIAL_KEY_CHARS[key] ?? capitalize(key)
})
props.entry.meta?.keyboardShortcut?.map(
(key) => SPECIAL_KEY_CHARS[key] ?? capitalize(key)
)
)
const emit = defineEmits<{

View File

@@ -13,7 +13,7 @@
</template>
<script setup lang="ts">
import { HoppCollection, HoppGQLRequest } from "@hoppscotch/data"
import { HoppCollection } from "@hoppscotch/data"
import { computed } from "vue"
import { graphqlCollectionStore } from "~/newstore/collections"
@@ -28,7 +28,7 @@ const pathFolders = computed(() => {
.slice(0, -1)
.map((x) => parseInt(x))
const pathItems: HoppCollection<HoppGQLRequest>[] = []
const pathItems: HoppCollection[] = []
let currentFolder =
graphqlCollectionStore.value.state[folderIndicies.shift()!]

View File

@@ -20,7 +20,7 @@
</template>
<script setup lang="ts">
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { HoppCollection } from "@hoppscotch/data"
import { computed } from "vue"
import { restCollectionStore } from "~/newstore/collections"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
@@ -36,7 +36,7 @@ const pathFolders = computed(() => {
.slice(0, -1)
.map((x) => parseInt(x))
const pathItems: HoppCollection<HoppRESTRequest>[] = []
const pathItems: HoppCollection[] = []
let currentFolder = restCollectionStore.value.state[folderIndicies.shift()!]
pathItems.push(currentFolder)

View File

@@ -16,7 +16,7 @@
autocomplete="off"
name="command"
:placeholder="`${t('app.type_a_command_search')}`"
class="flex flex-1 bg-transparent px-6 py-5 text-base text-secondaryDark"
class="flex flex-1 bg-transparent px-6 pt-5 pb-3 text-base text-secondaryDark"
/>
<HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" />
</div>
@@ -49,13 +49,15 @@
:text="`${t('state.nothing_found')} ‟${search}”`"
>
<template #icon>
<icon-lucide-search class="svg-icons pb-2 opacity-75" />
<icon-lucide-search class="svg-icons opacity-75" />
</template>
<template #body>
<HoppButtonSecondary
:label="t('action.clear')"
outline
@click="search = ''"
/>
</template>
<HoppButtonSecondary
:label="t('action.clear')"
outline
@click="search = ''"
/>
</HoppSmartPlaceholder>
</div>
<div
@@ -112,6 +114,7 @@ import {
WorkspaceSpotlightSearcherService,
} from "~/services/spotlight/searchers/workspace.searcher"
import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.searcher"
import { platform } from "~/platform"
const t = useI18n()
@@ -141,6 +144,10 @@ useService(WorkspaceSpotlightSearcherService)
useService(SwitchWorkspaceSpotlightSearcherService)
useService(InterceptorSpotlightSearcherService)
platform.spotlight?.additionalSearchers?.forEach((searcher) =>
useService(searcher)
)
const search = ref("")
const searchSession = ref<SpotlightSearchState>()

View File

@@ -14,14 +14,14 @@
></div>
<div class="relative flex flex-col">
<div
class="z-1 pointer-events-none absolute inset-0 bg-accent opacity-0 transition"
class="z-[1] pointer-events-none absolute inset-0 bg-accent opacity-0 transition"
:class="{
'opacity-25':
dragging && notSameDestination && notSameParentDestination,
}"
></div>
<div
class="z-3 group pointer-events-auto relative flex cursor-pointer items-stretch"
class="z-[3] group pointer-events-auto relative flex cursor-pointer items-stretch"
:draggable="!hasNoTeamAccess"
@dragstart="dragStart"
@drop="handelDrop($event)"
@@ -96,6 +96,7 @@
@keyup.e="edit?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.x="exportAction?.$el.click()"
@keyup.p="propertiesAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
@@ -159,6 +160,18 @@
}
"
/>
<HoppSmartItem
ref="propertiesAction"
:icon="IconSettings2"
:label="t('action.properties')"
:shortcut="['P']"
@click="
() => {
emit('edit-properties')
hide()
}
"
/>
</div>
</template>
</tippy>
@@ -193,8 +206,9 @@ 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 IconSettings2 from "~icons/lucide/settings-2"
import { ref, computed, watch } from "vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { HoppCollection } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { TippyComponent } from "vue-tippy"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
@@ -213,7 +227,7 @@ const props = withDefaults(
defineProps<{
id: string
parentID?: string | null
data: HoppCollection<HoppRESTRequest> | TeamCollection
data: HoppCollection | TeamCollection
/**
* Collection component can be used for both collections and folders.
* folderType is used to determine which one it is.
@@ -245,6 +259,7 @@ const emit = defineEmits<{
(event: "add-request"): void
(event: "add-folder"): void
(event: "edit-collection"): void
(event: "edit-properties"): void
(event: "export-data"): void
(event: "remove-collection"): void
(event: "drop-event", payload: DataTransfer): void
@@ -261,6 +276,7 @@ const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const propertiesAction = ref<TippyComponent | null>(null)
const dragging = ref(false)
const ordering = ref(false)
@@ -290,13 +306,13 @@ const collectionIcon = computed(() => {
if (props.isSelected) return IconCheckCircle
else if (!props.isOpen) return IconFolder
else if (props.isOpen) return IconFolderOpen
else return IconFolder
return IconFolder
})
const collectionName = computed(() => {
if ((props.data as HoppCollection<HoppRESTRequest>).name)
return (props.data as HoppCollection<HoppRESTRequest>).name
else return (props.data as TeamCollection).title
if ((props.data as HoppCollection).name)
return (props.data as HoppCollection).name
return (props.data as TeamCollection).title
})
watch(
@@ -424,9 +440,8 @@ const isCollLoading = computed(() => {
props.data.id
) {
return collectionMoveLoading.includes(props.data.id)
} else {
return false
}
return false
})
const resetDragState = () => {

View File

@@ -1,361 +1,585 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('modal.collections')"
styles="sm:max-w-md"
@close="hideModal"
>
<template #actions>
<HoppButtonSecondary
v-if="importerType !== null"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.go_back')"
:icon="IconArrowLeft"
@click="resetImport"
/>
</template>
<template #body>
<div v-if="importerType !== null" class="flex flex-col">
<div class="flex flex-col pb-4">
<div
v-for="(step, index) in importerSteps"
:key="`step-${index}`"
class="flex flex-col space-y-8"
>
<div v-if="step.name === 'FILE_IMPORT'" class="space-y-4">
<p class="flex items-center">
<span
class="mr-4 inline-flex flex-shrink-0 items-center justify-center rounded-full border-4 border-primary text-dividerDark"
:class="{
'!text-green-500': hasFile,
}"
>
<icon-lucide-check-circle class="svg-icons" />
</span>
<span>
{{ t(`${step.metadata.caption}`) }}
</span>
</p>
<p
class="ml-10 flex flex-col rounded border border-dashed border-dividerDark"
>
<input
id="inputChooseFileToImportFrom"
ref="inputChooseFileToImportFrom"
name="inputChooseFileToImportFrom"
type="file"
class="cursor-pointer p-4 text-secondary transition file:mr-2 file:cursor-pointer file:rounded file:border-0 file:bg-primaryLight file:px-4 file:py-2 file:text-secondary file:transition hover:text-secondaryDark hover:file:bg-primaryDark hover:file:text-secondaryDark"
:accept="step.metadata.acceptedFileTypes"
@change="onFileChange"
/>
</p>
</div>
<div v-else-if="step.name === 'URL_IMPORT'" class="space-y-4">
<p class="flex items-center">
<span
class="mr-4 inline-flex flex-shrink-0 items-center justify-center rounded-full border-4 border-primary text-dividerDark"
:class="{
'!text-green-500': hasGist,
}"
>
<icon-lucide-check-circle class="svg-icons" />
</span>
<span>
{{ t(`${step.metadata.caption}`) }}
</span>
</p>
<p class="ml-10 flex flex-col">
<input
v-model="inputChooseGistToImportFrom"
type="url"
class="input"
:placeholder="`${t('import.gist_url')}`"
/>
</p>
</div>
<div
v-else-if="step.name === 'TARGET_MY_COLLECTION'"
class="flex flex-col"
>
<div class="select-wrapper">
<select
v-model="mySelectedCollectionID"
autocomplete="off"
class="select"
autofocus
>
<option :key="undefined" :value="undefined" disabled selected>
{{ t("collection.select") }}
</option>
<option
v-for="(collection, collectionIndex) in myCollections"
:key="`collection-${collectionIndex}`"
:value="collectionIndex"
class="bg-primary"
>
{{ collection.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<HoppButtonPrimary
:label="t('import.title')"
:disabled="enableImportButton"
:loading="importingMyCollections"
@click="finishImport"
/>
</div>
<div v-else class="flex flex-col">
<HoppSmartExpand>
<template #body>
<HoppSmartItem
v-for="(importer, index) in importerModules"
:key="`importer-${index}`"
:icon="importer.icon"
:label="t(`${importer.name}`)"
@click="importerType = index"
/>
</template>
</HoppSmartExpand>
<hr />
<div class="flex flex-col space-y-2">
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:loading="exportingTeamCollections"
:label="t('export.as_json')"
@click="emit('export-json-collection')"
/>
<span
v-if="platform.platformFeatureFlags.exportAsGIST"
v-tippy="{ theme: 'tooltip' }"
:title="
!currentUser
? `${t('export.require_github')}`
: currentUser.provider !== 'github.com'
? `${t('export.require_github')}`
: undefined
"
class="flex"
>
<HoppSmartItem
:disabled="
!currentUser
? true
: currentUser.provider !== 'github.com'
? true
: false
"
:icon="IconGithub"
:loading="creatingGistCollection"
:label="t('export.create_secret_gist')"
@click="emit('create-collection-gist')"
/>
</span>
</div>
</div>
</template>
</HoppSmartModal>
<ImportExportBase
ref="collections-import-export"
modal-title="modal.collections"
:importer-modules="importerModules"
:exporter-modules="exporterModules"
@hide-modal="emit('hide-modal')"
/>
</template>
<script setup lang="ts">
import IconArrowLeft from "~icons/lucide/arrow-left"
import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, PropType, ref, watch } from "vue"
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"
import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { platform } from "~/platform"
import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
import { UrlSource } from "~/helpers/import-export/import/import-sources/UrlSource"
import IconFile from "~icons/lucide/file"
import {
hoppRESTImporter,
hoppInsomniaImporter,
hoppPostmanImporter,
toTeamsImporter,
hoppOpenAPIImporter,
} from "~/helpers/import-export/import/importers"
import { defineStep } from "~/composables/step-components"
import { PropType, computed, ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { HoppCollection } from "@hoppscotch/data"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
import { StepReturnValue } from "~/helpers/import-export/steps"
import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconOpenAPI from "~icons/lucide/file"
import IconPostman from "~icons/hopp/postman"
import IconInsomnia from "~icons/hopp/insomnia"
import IconGithub from "~icons/lucide/github"
import IconLink from "~icons/lucide/link"
import IconUser from "~icons/lucide/user"
import { useReadonlyStream } from "~/composables/stream"
import { getTeamCollectionJSON } from "~/helpers/backend/helpers"
import { platform } from "~/platform"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { collectionsGistExporter } from "~/helpers/import-export/export/gistExport"
import { myCollectionsExporter } from "~/helpers/import-export/export/myCollections"
import { teamCollectionsExporter } from "~/helpers/import-export/export/teamCollections"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import { ImporterOrExporter } from "~/components/importExport/types"
const toast = useToast()
const t = useI18n()
const toast = useToast()
type CollectionType = "team-collections" | "my-collections"
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections" }
const props = defineProps({
show: {
type: Boolean,
default: false,
required: true,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
type: Object as PropType<CollectionType>,
default: () => ({
type: "my-collections",
selectedTeam: undefined,
}),
required: true,
},
exportingTeamCollections: {
type: Boolean,
default: false,
required: false,
},
creatingGistCollection: {
type: Boolean,
default: false,
required: false,
},
importingMyCollections: {
type: Boolean,
default: false,
required: false,
},
})
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "update-team-collections"): void
(e: "export-json-collection"): void
(e: "create-collection-gist"): void
(e: "import-to-teams", payload: HoppCollection<HoppRESTRequest>[]): void
}>()
const hasFile = ref(false)
const hasGist = ref(false)
const importerType = ref<number | null>(null)
const stepResults = ref<StepReturnValue[]>([])
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const mySelectedCollectionID = ref<number | undefined>(undefined)
const inputChooseGistToImportFrom = ref<string>("")
const importerModules = computed(() =>
RESTCollectionImporters.filter(
(i) => i.applicableTo?.includes(props.collectionsType) ?? true
)
)
const importerModule = computed(() => {
if (importerType.value === null) return null
return importerModules.value[importerType.value]
})
const importerSteps = computed(() => importerModule.value?.steps ?? null)
const enableImportButton = computed(
() => !(stepResults.value.length === importerSteps.value?.length)
)
watch(mySelectedCollectionID, (newValue) => {
if (newValue === undefined) return
stepResults.value = []
stepResults.value.push(newValue)
})
watch(inputChooseGistToImportFrom, (url) => {
stepResults.value = []
if (url === "") {
hasGist.value = false
} else {
hasGist.value = true
stepResults.value.push(inputChooseGistToImportFrom.value)
}
})
const myCollections = useReadonlyStream(restCollections$, [])
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const importerAction = async (stepResults: StepReturnValue[]) => {
if (!importerModule.value) return
const showImportFailedError = () => {
toast.error(t("import.failed"))
}
pipe(
await importerModule.value.importer(stepResults as any)(),
E.match(
(err) => {
failedImport()
console.error("error", err)
},
(result) => {
if (props.collectionsType === "team-collections") {
emit("import-to-teams", result)
} else {
appendRESTCollections(result)
const handleImportToStore = async (collections: HoppCollection[]) => {
const importResult =
props.collectionsType.type === "my-collections"
? await importToPersonalWorkspace(collections)
: await importToTeamsWorkspace(collections)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
importer: importerModule.value!.name,
platform: "rest",
workspaceType: "personal",
})
if (E.isRight(importResult)) {
toast.success(t("state.file_imported"))
emit("hide-modal")
} else {
toast.error(t("import.failed"))
}
}
fileImported()
}
}
)
const importToPersonalWorkspace = (collections: HoppCollection[]) => {
appendRESTCollections(collections)
return E.right({
success: true,
})
}
function translateToTeamCollectionFormat(x: HoppCollection) {
const folders: HoppCollection[] = (x.folders ?? []).map(
translateToTeamCollectionFormat
)
}
const finishImport = async () => {
await importerAction(stepResults.value)
}
const onFileChange = () => {
stepResults.value = []
const inputFileToImport = inputChooseFileToImportFrom.value[0]
if (!inputFileToImport) {
hasFile.value = false
return
const data = {
auth: x.auth,
headers: x.headers,
}
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
inputChooseFileToImportFrom.value[0].value = ""
hasFile.value = false
toast.show(t("action.choose_file").toString())
return
const obj = {
...x,
folders,
data,
}
const reader = new FileReader()
if (x.id) obj.id = x.id
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
hasFile.value = false
toast.show(t("action.choose_file").toString())
return obj
}
const importToTeamsWorkspace = async (collections: HoppCollection[]) => {
if (!hasTeamWriteAccess.value || !selectedTeamID.value) {
return E.left({
success: false,
})
}
const transformedCollection = collections.map((collection) =>
translateToTeamCollectionFormat(collection)
)
const res = await toTeamsImporter(
JSON.stringify(transformedCollection),
selectedTeamID.value
)()
return E.isRight(res)
? E.right({ success: true })
: E.left({
success: false,
})
}
const emit = defineEmits<{
(e: "hide-modal"): () => void
}>()
const isHoppMyCollectionExporterInProgress = ref(false)
const isHoppTeamCollectionExporterInProgress = ref(false)
const isHoppGistCollectionExporterInProgress = ref(false)
const isTeamWorkspace = computed(() => {
return props.collectionsType.type === "team-collections"
})
const HoppRESTImporter: ImporterOrExporter = {
metadata: {
id: "hopp_rest",
name: "import.from_json",
title: "import.from_json_description",
icon: IconFolderPlus,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: FileSource({
caption: "import.from_file",
acceptedFileTypes: ".json",
onImportFromFile: async (content) => {
const res = await hoppRESTImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_json",
platform: "rest",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
}
const HoppMyCollectionImporter: ImporterOrExporter = {
metadata: {
id: "hopp_my_collection",
name: "import.from_my_collections",
title: "import.from_my_collections_description",
icon: IconUser,
disabled: false,
applicableTo: ["team-workspace"],
},
component: defineStep("my_collection_import", MyCollectionImport, () => ({
async onImportFromMyCollection(content) {
handleImportToStore([content])
// our analytics consider this as an export event, so keeping compatibility with that
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "import_to_teams",
platform: "rest",
})
},
})),
}
const HoppOpenAPIImporter: ImporterOrExporter = {
metadata: {
id: "hopp_openapi",
name: "import.from_openapi",
title: "import.from_openapi_description",
icon: IconOpenAPI,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
supported_sources: [
{
id: "file_import",
name: "import.from_file",
icon: IconFile,
step: FileSource({
caption: "import.from_file",
acceptedFileTypes: ".json, .yaml, .yml",
onImportFromFile: async (content) => {
const res = await hoppOpenAPIImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_openapi",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
},
{
id: "url_import",
name: "import.from_url",
icon: IconLink,
step: UrlSource({
caption: "import.from_url",
onImportFromURL: async (content) => {
const res = await hoppOpenAPIImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_openapi",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
},
],
}
const HoppPostmanImporter: ImporterOrExporter = {
metadata: {
id: "hopp_postman",
name: "import.from_postman",
title: "import.from_postman_description",
icon: IconPostman,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: FileSource({
caption: "import.from_file",
acceptedFileTypes: ".json",
onImportFromFile: async (content) => {
const res = await hoppPostmanImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_postman",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
}
const HoppInsomniaImporter: ImporterOrExporter = {
metadata: {
id: "hopp_insomnia",
name: "import.from_insomnia",
title: "import.from_insomnia_description",
icon: IconInsomnia,
disabled: true,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: FileSource({
caption: "import.from_file",
acceptedFileTypes: ".json",
onImportFromFile: async (content) => {
const res = await hoppInsomniaImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_insomnia",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
}
const HoppGistImporter: ImporterOrExporter = {
metadata: {
id: "hopp_gist",
name: "import.from_gist",
title: "import.from_gist_description",
icon: IconGithub,
disabled: true,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: GistSource({
caption: "import.from_url",
onImportFromGist: async (content) => {
if (E.isLeft(content)) {
showImportFailedError()
return
}
const res = await hoppRESTImporter(content.right)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_gist",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
}
const HoppMyCollectionsExporter: ImporterOrExporter = {
metadata: {
id: "hopp_my_collections",
name: "export.as_json",
title: "action.download_file",
icon: IconUser,
disabled: false,
applicableTo: ["personal-workspace"],
isLoading: isHoppMyCollectionExporterInProgress,
},
action: () => {
if (!myCollections.value.length) {
return toast.error(t("error.no_collections_to_export"))
}
isHoppMyCollectionExporterInProgress.value = true
const message = initializeDownloadCollection(
myCollectionsExporter(myCollections.value),
"Collections"
)
if (E.isRight(message)) {
toast.success(t(message.right))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
}
isHoppMyCollectionExporterInProgress.value = false
},
}
const HoppTeamCollectionsExporter: ImporterOrExporter = {
metadata: {
id: "hopp_team_collections",
name: "export.as_json",
title: "export.as_json_description",
icon: IconUser,
disabled: false,
applicableTo: ["team-workspace"],
isLoading: isHoppTeamCollectionExporterInProgress,
},
action: async () => {
isHoppTeamCollectionExporterInProgress.value = true
if (
props.collectionsType.type === "team-collections" &&
props.collectionsType.selectedTeam
) {
const res = await teamCollectionsExporter(
props.collectionsType.selectedTeam.id
)
if (E.isRight(res)) {
const { exportCollectionsToJSON } = res.right
if (!JSON.parse(exportCollectionsToJSON).length) {
isHoppTeamCollectionExporterInProgress.value = false
return toast.error(t("error.no_collections_to_export"))
}
initializeDownloadCollection(
exportCollectionsToJSON,
"team-collections"
)
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
} else {
toast.error(res.left.error.toString())
}
}
isHoppTeamCollectionExporterInProgress.value = false
},
}
const HoppGistCollectionsExporter: ImporterOrExporter = {
metadata: {
id: "create_secret_gist",
name: "export.create_secret_gist",
icon: IconGithub,
disabled: !currentUser.value
? true
: currentUser.value.provider !== "github.com",
title: t("export.create_secret_gist"),
applicableTo: ["personal-workspace", "team-workspace"],
isLoading: isHoppGistCollectionExporterInProgress,
},
action: async () => {
isHoppGistCollectionExporterInProgress.value = true
const collectionJSON = await getCollectionJSON()
const accessToken = currentUser.value?.accessToken
if (!accessToken) {
toast.error(t("error.something_went_wrong"))
isHoppGistCollectionExporterInProgress.value = false
return
}
stepResults.value.push(content)
hasFile.value = !!content?.length
if (E.isRight(collectionJSON)) {
collectionsGistExporter(collectionJSON.right, accessToken)
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "gist",
platform: "rest",
})
}
isHoppGistCollectionExporterInProgress.value = false
},
}
const importerModules = computed(() => {
const enabledImporters = [
HoppRESTImporter,
HoppMyCollectionImporter,
HoppOpenAPIImporter,
HoppPostmanImporter,
HoppInsomniaImporter,
HoppGistImporter,
]
const isTeams = props.collectionsType.type === "team-collections"
return enabledImporters.filter((importer) => {
return isTeams
? importer.metadata.applicableTo.includes("team-workspace")
: importer.metadata.applicableTo.includes("personal-workspace")
})
})
const exporterModules = computed(() => {
const enabledExporters = [
HoppMyCollectionsExporter,
HoppTeamCollectionsExporter,
]
if (platform.platformFeatureFlags.exportAsGIST) {
enabledExporters.push(HoppGistCollectionsExporter)
}
reader.readAsText(inputFileToImport.files[0])
}
return enabledExporters.filter((exporter) => {
return exporter.metadata.applicableTo.includes(
props.collectionsType.type === "my-collections"
? "personal-workspace"
: "team-workspace"
)
})
})
const fileImported = () => {
toast.success(t("state.file_imported").toString())
hideModal()
}
const failedImport = () => {
toast.error(t("import.failed").toString())
}
const hideModal = () => {
resetImport()
emit("hide-modal")
}
const hasTeamWriteAccess = computed(() => {
const { collectionsType } = props
const resetImport = () => {
importerType.value = null
hasFile.value = false
hasGist.value = false
stepResults.value = []
inputChooseFileToImportFrom.value = ""
inputChooseGistToImportFrom.value = ""
mySelectedCollectionID.value = undefined
const isTeamCollection = collectionsType.type === "team-collections"
if (!isTeamCollection || !collectionsType.selectedTeam) {
return false
}
return (
collectionsType.selectedTeam.myRole === "EDITOR" ||
collectionsType.selectedTeam.myRole === "OWNER"
)
})
const selectedTeamID = computed(() => {
const { collectionsType } = props
return collectionsType.type === "team-collections"
? collectionsType.selectedTeam?.id
: undefined
})
const myCollections = useReadonlyStream(restCollections$, [])
const getCollectionJSON = async () => {
if (
props.collectionsType.type === "team-collections" &&
props.collectionsType.selectedTeam?.id
) {
const res = await getTeamCollectionJSON(
props.collectionsType.selectedTeam?.id
)
return E.isRight(res)
? E.right(res.right.exportCollectionsToJSON)
: E.left(res.left)
}
if (props.collectionsType.type === "my-collections") {
return E.right(JSON.stringify(myCollections.value, null, 2))
}
return E.left("INVALID_SELECTED_TEAM_OR_INVALID_COLLECTION_TYPE")
}
</script>

View File

@@ -71,6 +71,13 @@
collection: node.data.data.data,
})
"
@edit-properties="
node.data.type === 'collections' &&
emit('edit-properties', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
@@ -139,6 +146,13 @@
folder: node.data.data.data,
})
"
@edit-properties="
node.data.type === 'folders' &&
emit('edit-properties', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'folders' &&
emit('export-data', node.data.data.data)
@@ -222,6 +236,12 @@
requestIndex: pathToIndex(node.id),
})
"
@share-request="
node.data.type === 'requests' &&
emit('share-request', {
request: node.data.data.data,
})
"
@drag-request="
dragRequest($event, {
folderPath: node.data.data.parentIndex,
@@ -248,7 +268,7 @@
:text="`${t('state.nothing_found')}${filterText}`"
>
<template #icon>
<icon-lucide-search class="svg-icons pb-2 opacity-75" />
<icon-lucide-search class="svg-icons opacity-75" />
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
@@ -257,27 +277,29 @@
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }}
</span>
<div class="flex flex-col items-stretch gap-4">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
@click="emit('display-modal-import-export')"
/>
<HoppButtonSecondary
:icon="IconPlus"
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
<template #body>
<div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }}
</span>
<div class="flex flex-col items-stretch gap-4">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
@click="emit('display-modal-import-export')"
/>
<HoppButtonSecondary
:icon="IconPlus"
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
</div>
</div>
</div>
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'"
@@ -285,18 +307,20 @@
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
<template #body>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'"
@@ -334,7 +358,7 @@ export type Collection = {
isLastItem: boolean
data: {
parentIndex: null
data: HoppCollection<HoppRESTRequest>
data: HoppCollection
}
}
@@ -343,7 +367,7 @@ type Folder = {
isLastItem: boolean
data: {
parentIndex: string
data: HoppCollection<HoppRESTRequest>
data: HoppCollection
}
}
@@ -370,7 +394,7 @@ type CollectionType =
const props = defineProps({
filteredCollections: {
type: Array as PropType<HoppCollection<HoppRESTRequest>[]>,
type: Array as PropType<HoppCollection[]>,
default: () => [],
required: true,
},
@@ -402,28 +426,35 @@ const emit = defineEmits<{
event: "add-request",
payload: {
path: string
folder: HoppCollection<HoppRESTRequest>
folder: HoppCollection
}
): void
(
event: "add-folder",
payload: {
path: string
folder: HoppCollection<HoppRESTRequest>
folder: HoppCollection
}
): void
(
event: "edit-collection",
payload: {
collectionIndex: string
collection: HoppCollection<HoppRESTRequest>
collection: HoppCollection
}
): void
(
event: "edit-folder",
payload: {
folderPath: string
folder: HoppCollection<HoppRESTRequest>
folder: HoppCollection
}
): void
(
event: "edit-properties",
payload: {
collectionIndex: string
collection: HoppCollection
}
): void
(
@@ -441,7 +472,7 @@ const emit = defineEmits<{
request: HoppRESTRequest
}
): void
(event: "export-data", payload: HoppCollection<HoppRESTRequest>): void
(event: "export-data", payload: HoppCollection): void
(event: "remove-collection", payload: string): void
(event: "remove-folder", payload: string): void
(
@@ -460,6 +491,12 @@ const emit = defineEmits<{
isActive: boolean
}
): void
(
event: "share-request",
payload: {
request: HoppRESTRequest
}
): void
(
event: "drop-request",
payload: {
@@ -526,13 +563,12 @@ const isSelected = ({
props.picked.folderPath === folderPath &&
props.picked.requestIndex === requestIndex
)
} else {
return (
props.picked &&
props.picked.pickedType === "my-folder" &&
props.picked.folderPath === folderPath
)
}
return (
props.picked &&
props.picked.pickedType === "my-folder" &&
props.picked.folderPath === folderPath
)
}
const tabs = useService(RESTTabService)
@@ -650,10 +686,10 @@ const updateCollectionOrder = (
type MyCollectionNode = Collection | Folder | Requests
class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
constructor(public data: Ref<HoppCollection<HoppRESTRequest>[]>) {}
constructor(public data: Ref<HoppCollection[]>) {}
navigateToFolderWithIndexPath(
collections: HoppCollection<HoppRESTRequest>[],
collections: HoppCollection[],
indexPaths: number[]
) {
if (indexPaths.length === 0) return null
@@ -729,11 +765,10 @@ class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
status: "loaded",
data: data,
} as ChildrenResult<Folder | Requests>
} else {
return {
status: "loaded",
data: [],
}
}
return {
status: "loaded",
data: [],
}
})
}

View File

@@ -0,0 +1,164 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('collection.properties')"
:full-width-body="true"
@close="hideModal"
>
<template #body>
<HoppSmartTabs
v-model="selectedOptionTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10 !-py-4"
render-inactive-tabs
>
<HoppSmartTab :id="'headers'" :label="`${t('tab.headers')}`">
<HttpHeaders
v-model="editableCollection"
:is-collection-property="true"
@change-tab="changeOptionTab"
/>
<div
class="bg-bannerInfo px-4 py-2 flex items-center sticky bottom-0"
>
<icon-lucide-info class="svg-icons mr-2" />
{{ t("helpers.collection_properties_header") }}
</div>
</HoppSmartTab>
<HoppSmartTab
:id="'authorization'"
:label="`${t('tab.authorization')}`"
>
<HttpAuthorization
v-model="editableCollection.auth"
:is-collection-property="true"
:is-root-collection="editingProperties?.isRootCollection"
:inherited-properties="editingProperties?.inheritedProperties"
/>
<div
class="bg-bannerInfo px-4 py-2 flex items-center sticky bottom-0"
>
<icon-lucide-info class="svg-icons mr-2" />
{{ t("helpers.collection_properties_authorization") }}
</div>
</HoppSmartTab>
</HoppSmartTabs>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.save')"
:loading="loadingState"
outline
@click="saveEditedCollection"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { watch, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { HoppCollection } from "@hoppscotch/data"
import { RESTOptionTabs } from "../http/RequestOptions.vue"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { clone } from "lodash-es"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
const t = useI18n()
type EditingProperties = {
collection: HoppCollection | TeamCollection | null
isRootCollection: boolean
path: string
inheritedProperties: HoppInheritedProperty | undefined
}
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
editingProperties: EditingProperties | null
}>(),
{
show: false,
loadingState: false,
editingProperties: null,
}
)
const emit = defineEmits<{
(e: "set-collection-properties", newCollection: any): void
(e: "hide-modal"): void
}>()
const editableCollection = ref({
body: {
contentType: null,
body: null,
},
headers: [],
auth: {
authType: "inherit",
authActive: false,
},
}) as any
const selectedOptionTab = ref("headers")
const changeOptionTab = (tab: RESTOptionTabs) => {
selectedOptionTab.value = tab
}
watch(
() => props.show,
(show) => {
if (show && props.editingProperties?.collection) {
editableCollection.value.auth = clone(
props.editingProperties.collection.auth
)
editableCollection.value.headers = clone(
props.editingProperties.collection.headers
)
} else {
editableCollection.value = {
body: {
contentType: null,
body: null,
},
headers: [],
auth: {
authType: "inherit",
authActive: false,
},
}
}
}
)
const saveEditedCollection = () => {
if (!props.editingProperties) return
const finalCollection = clone(editableCollection.value)
delete finalCollection.body
const collection = {
path: props.editingProperties.path,
collection: {
...props.editingProperties.collection,
...finalCollection,
},
isRootCollection: props.editingProperties.isRootCollection,
}
emit("set-collection-properties", collection)
}
const hideModal = () => {
emit("hide-modal")
}
</script>

View File

@@ -28,8 +28,7 @@
>
<span
class="pointer-events-none flex w-16 items-center justify-center truncate px-2"
:class="requestLabelColor"
:style="{ color: requestLabelColor }"
:style="{ color: getMethodLabelColorClassOf(request) }"
>
<component
:is="IconCheckCircle"
@@ -94,6 +93,7 @@
@keyup.e="edit?.$el.click()"
@keyup.d="duplicate?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.s="shareAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
@@ -133,6 +133,18 @@
}
"
/>
<HoppSmartItem
ref="shareAction"
:icon="IconShare2"
:label="t('action.share')"
:shortcut="['S']"
@click="
() => {
emit('share-request')
hide()
}
"
/>
</div>
</template>
</tippy>
@@ -162,6 +174,7 @@ import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconShare2 from "~icons/lucide/share-2"
import { ref, PropType, watch, computed } from "vue"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
@@ -240,6 +253,7 @@ const emit = defineEmits<{
(event: "duplicate-request"): void
(event: "remove-request"): void
(event: "select-request"): void
(event: "share-request"): void
(event: "drag-request", payload: DataTransfer): void
(event: "update-request-order", payload: DataTransfer): void
(event: "update-last-request-order", payload: DataTransfer): void
@@ -250,6 +264,7 @@ const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null)
const shareAction = ref<HTMLButtonElement | null>(null)
const dragging = ref(false)
const ordering = ref(false)
@@ -261,10 +276,6 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
parentID: "",
})
const requestLabelColor = computed(() =>
getMethodLabelColorClassOf(props.request)
)
watch(
() => props.duplicateLoading,
(val) => {
@@ -363,9 +374,8 @@ const updateLastItemOrder = (e: DragEvent) => {
const isRequestLoading = computed(() => {
if (props.requestMoveLoading.length > 0 && props.requestID) {
return props.requestMoveLoading.includes(props.requestID)
} else {
return false
}
return false
})
const resetDragState = () => {

View File

@@ -74,6 +74,7 @@ import { Picked } from "~/helpers/types/HoppPicked"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import {
cascadeParentCollectionForHeaderAuth,
editGraphqlRequest,
editRESTRequest,
saveGraphqlRequestAs,
@@ -141,9 +142,8 @@ const reqName = computed(() => {
return props.request.name
} else if (props.mode === "rest") {
return restRequestName.value
} else {
return gqlRequestName.value
}
return gqlRequestName.value
})
const requestName = ref(reqName.value)
@@ -240,6 +240,16 @@ const saveRequestAs = async () => {
},
}
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
`${picked.value.collectionIndex}`,
"rest"
)
RESTTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
@@ -267,6 +277,16 @@ const saveRequestAs = async () => {
},
}
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"rest"
)
RESTTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
@@ -295,6 +315,16 @@ const saveRequestAs = async () => {
},
}
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"rest"
)
RESTTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
@@ -379,6 +409,16 @@ const saveRequestAs = async () => {
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
} else if (picked.value.pickedType === "gql-my-folder") {
// TODO: Check for GQL request ?
@@ -394,6 +434,16 @@ const saveRequestAs = async () => {
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
} else if (picked.value.pickedType === "gql-my-collection") {
// TODO: Check for GQL request ?
@@ -409,6 +459,16 @@ const saveRequestAs = async () => {
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
`${picked.value.collectionIndex}`,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
}
}
@@ -480,21 +540,20 @@ const getErrorMessage = (err: GQLError<string>) => {
console.error(err)
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "team_coll/short_title":
return t("collection.name_length_insufficient")
case "team/invalid_coll_id":
return t("team.invalid_id")
case "team/not_required_role":
return t("profile.no_permission")
case "team_req/not_required_role":
return t("profile.no_permission")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
switch (err.error) {
case "team_coll/short_title":
return t("collection.name_length_insufficient")
case "team/invalid_coll_id":
return t("team.invalid_id")
case "team/not_required_role":
return t("profile.no_permission")
case "team_req/not_required_role":
return t("profile.no_permission")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
</script>

View File

@@ -15,12 +15,12 @@
class="!rounded-none"
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('add.new')"
:label="t('action.new')"
/>
<HoppButtonSecondary
v-else
:icon="IconPlus"
:label="t('add.new')"
:label="t('action.new')"
class="!rounded-none"
@click="emit('display-modal-add')"
/>
@@ -88,6 +88,13 @@
collection: node.data.data.data,
})
"
@edit-properties="
node.data.type === 'collections' &&
emit('edit-properties', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
@@ -159,6 +166,13 @@
folder: node.data.data.data,
})
"
@edit-properties="
node.data.type === 'folders' &&
emit('edit-properties', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'folders' &&
emit('export-data', node.data.data.data)
@@ -238,6 +252,13 @@
selectRequest({
request: node.data.data.data.request,
requestIndex: node.data.data.data.id,
folderPath: getPath(node.id),
})
"
@share-request="
node.data.type === 'requests' &&
emit('share-request', {
request: node.data.data.data.request,
})
"
@drag-request="
@@ -268,33 +289,37 @@
:text="t('empty.collections')"
@drop.stop
>
<div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }}
</span>
<div class="flex flex-col items-stretch gap-4">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
:disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="
hasNoTeamAccess ? null : emit('display-modal-import-export')
"
/>
<HoppButtonSecondary
:icon="IconPlus"
:label="t('add.new')"
filled
outline
:disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="hasNoTeamAccess ? null : emit('display-modal-add')"
/>
<template #body>
<div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }}
</span>
<div class="flex flex-col items-stretch gap-4">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
:disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="
hasNoTeamAccess
? null
: emit('display-modal-import-export')
"
/>
<HoppButtonSecondary
:icon="IconPlus"
:label="t('add.new')"
filled
outline
:disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="hasNoTeamAccess ? null : emit('display-modal-add')"
/>
</div>
</div>
</div>
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'"
@@ -303,18 +328,20 @@
:text="t('empty.collections')"
@drop.stop
>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
<template #body>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'"
@@ -440,6 +467,13 @@ const emit = defineEmits<{
folder: TeamCollection
}
): void
(
event: "edit-properties",
payload: {
collectionIndex: string
collection: TeamCollection
}
): void
(
event: "edit-request",
payload: {
@@ -470,7 +504,13 @@ const emit = defineEmits<{
request: HoppRESTRequest
requestIndex: string
isActive: boolean
folderPath?: string | undefined
folderPath: string
}
): void
(
event: "share-request",
payload: {
request: HoppRESTRequest
}
): void
(
@@ -512,6 +552,12 @@ const emit = defineEmits<{
(event: "display-modal-import-export"): void
}>()
const getPath = (path: string) => {
const pathArray = path.split("/")
pathArray.pop()
return pathArray.join("/")
}
const teamCollectionsList = toRef(props, "teamCollectionList")
const hasNoTeamAccess = computed(
@@ -542,13 +588,12 @@ const isSelected = ({
props.picked.pickedType === "teams-request" &&
props.picked.requestID === requestID
)
} else {
return (
props.picked &&
props.picked.pickedType === "teams-folder" &&
props.picked.folderID === folderID
)
}
return (
props.picked &&
props.picked.pickedType === "teams-folder" &&
props.picked.folderID === folderID
)
}
const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
@@ -569,6 +614,7 @@ const isActiveRequest = (requestID: string) => {
const selectRequest = (data: {
request: HoppRESTRequest
requestIndex: string
folderPath: string | null
}) => {
const { request, requestIndex } = data
if (props.saveRequest) {
@@ -581,6 +627,7 @@ const selectRequest = (data: {
request: request,
requestIndex: requestIndex,
isActive: isActiveRequest(requestIndex),
folderPath: data.folderPath,
})
}
}
@@ -714,82 +761,78 @@ class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
return {
status: "loading",
}
} else {
const data = this.data.value.map((item, index) => ({
id: item.id,
}
const data = this.data.value.map((item, index) => ({
id: item.id,
data: {
isLastItem: index === this.data.value.length - 1,
type: "collections",
data: {
isLastItem: index === this.data.value.length - 1,
type: "collections",
data: {
parentIndex: null,
data: item,
},
parentIndex: null,
data: item,
},
}))
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamCollections>
}
} else {
const parsedID = id.split("/")[id.split("/").length - 1]
},
}))
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamCollections>
}
const parsedID = id.split("/")[id.split("/").length - 1]
!props.teamLoadingCollections.includes(parsedID) &&
emit("expand-team-collection", parsedID)
!props.teamLoadingCollections.includes(parsedID) &&
emit("expand-team-collection", parsedID)
if (props.teamLoadingCollections.includes(parsedID)) {
return {
status: "loading",
}
} else {
const items = this.findCollInTree(this.data.value, parsedID)
if (items) {
const data = [
...(items.children
? items.children.map((item, index) => ({
id: `${id}/${item.id}`,
data: {
isLastItem:
items.children && items.children.length > 1
? index === items.children.length - 1
: false,
type: "folders",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
...(items.requests
? items.requests.map((item, index) => ({
id: `${id}/${item.id}`,
data: {
isLastItem:
items.requests && items.requests.length > 1
? index === items.requests.length - 1
: false,
type: "requests",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
]
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamFolder | TeamRequests>
} else {
return {
status: "loaded",
data: [],
}
}
if (props.teamLoadingCollections.includes(parsedID)) {
return {
status: "loading",
}
}
const items = this.findCollInTree(this.data.value, parsedID)
if (items) {
const data = [
...(items.children
? items.children.map((item, index) => ({
id: `${id}/${item.id}`,
data: {
isLastItem:
items.children && items.children.length > 1
? index === items.children.length - 1
: false,
type: "folders",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
...(items.requests
? items.requests.map((item, index) => ({
id: `${id}/${item.id}`,
data: {
isLastItem:
items.requests && items.requests.length > 1
? index === items.requests.length - 1
: false,
type: "requests",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
]
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamFolder | TeamRequests>
}
return {
status: "loaded",
data: [],
}
})
}
}

View File

@@ -32,58 +32,58 @@
</HoppSmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { HoppGQLRequest, makeCollection } from "@hoppscotch/data"
import { makeCollection } from "@hoppscotch/data"
import { addGraphqlCollection } from "~/newstore/collections"
import { platform } from "~/platform"
export default defineComponent({
props: {
show: Boolean,
},
emits: ["hide-modal"],
setup() {
return {
toast: useToast(),
t: useI18n(),
}
},
data() {
return {
name: null as string | null,
}
},
methods: {
addNewCollection() {
if (!this.name) {
this.toast.error(`${this.t("collection.invalid_name")}`)
return
}
const t = useI18n()
const toast = useToast()
addGraphqlCollection(
makeCollection<HoppGQLRequest>({
name: this.name,
folders: [],
requests: [],
})
)
defineProps<{
show: boolean
}>()
this.hideModal()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
isRootCollection: true,
platform: "gql",
workspaceType: "personal",
})
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
const name = ref<string | null>(null)
const addNewCollection = () => {
if (!name.value) {
toast.error(`${t("collection.invalid_name")}`)
return
}
addGraphqlCollection(
makeCollection({
name: name.value,
folders: [],
requests: [],
auth: {
authType: "inherit",
authActive: true,
},
headers: [],
})
)
hideModal()
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
isRootCollection: true,
platform: "gql",
workspaceType: "personal",
})
}
const hideModal = () => {
name.value = null
emit("hide-modal")
}
</script>

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('folder.new')"
@close="$emit('hide-modal')"
@close="hideModal"
>
<template #body>
<HoppSmartInput
@@ -32,47 +32,49 @@
</HoppSmartModal>
</template>
<script lang="ts">
<script setup lang="ts">
import { ref } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { defineComponent } from "vue"
export default defineComponent({
props: {
show: Boolean,
folderPath: { type: String, default: null },
collectionIndex: { type: Number, default: null },
},
emits: ["hide-modal", "add-folder"],
setup() {
return {
toast: useToast(),
t: useI18n(),
const t = useI18n()
const toast = useToast()
const props = defineProps<{
show: boolean
folderPath?: string
collectionIndex: number
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(
e: "add-folder",
v: {
name: string
path: string | undefined
}
},
data() {
return {
name: null,
}
},
methods: {
addFolder() {
if (!this.name) {
this.toast.error(`${this.t("folder.name_length_insufficient")}`)
return
}
): void
}>()
this.$emit("add-folder", {
name: this.name,
path: this.folderPath || `${this.collectionIndex}`,
})
const name = ref<string | null>(null)
this.hideModal()
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
const addFolder = () => {
if (!name.value) {
toast.error(`${t("folder.name_length_insufficient")}`)
return
}
emit("add-folder", {
name: name.value,
path: props.folderPath || `${props.collectionIndex}`,
})
hideModal()
}
const hideModal = () => {
name.value = null
emit("hide-modal")
}
</script>

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