Compare commits

..

12 Commits

329 changed files with 8992 additions and 10714 deletions

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,5 @@
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
import { import { Args, ID, Query, ResolveField, Resolver } from '@nestjs/graphql';
Args,
ID,
Mutation,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { Infra } from './infra.model'; import { Infra } from './infra.model';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@@ -23,21 +16,11 @@ import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model'; import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { GqlAdmin } from './decorators/gql-admin.decorator'; import { GqlAdmin } from './decorators/gql-admin.decorator';
import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model'; import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model';
import { InfraConfig } from 'src/infra-config/infra-config.model';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
import {
EnableAndDisableSSOArgs,
InfraConfigArgs,
} from 'src/infra-config/input-args';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => Infra) @Resolver(() => Infra)
export class InfraResolver { export class InfraResolver {
constructor( constructor(private adminService: AdminService) {}
private adminService: AdminService,
private infraConfigService: InfraConfigService,
) {}
@Query(() => Infra, { @Query(() => Infra, {
description: 'Fetch details of the Infrastructure', description: 'Fetch details of the Infrastructure',
@@ -239,76 +222,4 @@ export class InfraResolver {
userEmail, userEmail,
); );
} }
@Query(() => [InfraConfig], {
description: 'Retrieve configuration details for the instance',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async infraConfigs(
@Args({
name: 'configNames',
type: () => [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,69 +20,51 @@ import { ShortcodeModule } from './shortcode/shortcode.module';
import { COOKIES_NOT_FOUND } from './errors'; import { COOKIES_NOT_FOUND } from './errors';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { InfraConfigModule } from './infra-config/infra-config.module';
import { loadInfraConfiguration } from './infra-config/helper';
import { MailerModule } from './mailer/mailer.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ GraphQLModule.forRoot<ApolloDriverConfig>({
isGlobal: true, buildSchemaOptions: {
load: [async () => loadInfraConfiguration()], numberScalarMode: 'integer',
}),
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
playground: configService.get('PRODUCTION') !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
},
},
context: ({ req, res, connection }) => ({
req,
res,
connection,
}),
};
}, },
}), playground: process.env.PRODUCTION !== 'true',
ThrottlerModule.forRootAsync({ autoSchemaFile: true,
imports: [ConfigModule], installSubscriptionHandlers: true,
inject: [ConfigService], subscriptions: {
useFactory: async (configService: ConfigService) => [ 'subscriptions-transport-ws': {
{ path: '/graphql',
ttl: +configService.get('RATE_LIMIT_TTL'), onConnect: (_, websocket) => {
limit: +configService.get('RATE_LIMIT_MAX'), 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,
}), }),
MailerModule.register(), ThrottlerModule.forRoot([
{
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
},
]),
UserModule, UserModule,
AuthModule.register(), AuthModule,
AdminModule, AdminModule,
UserSettingsModule, UserSettingsModule,
UserEnvironmentsModule, UserEnvironmentsModule,
@@ -95,7 +77,6 @@ import { MailerModule } from './mailer/mailer.module';
TeamInvitationModule, TeamInvitationModule,
UserCollectionModule, UserCollectionModule,
ShortcodeModule, ShortcodeModule,
InfraConfigModule,
], ],
providers: [GQLComplexityPlugin], providers: [GQLComplexityPlugin],
controllers: [AppController], controllers: [AppController],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -644,41 +644,3 @@ export const SHORTCODE_INVALID_PROPERTIES_JSON =
*/ */
export const SHORTCODE_PROPERTIES_NOT_FOUND = export const SHORTCODE_PROPERTIES_NOT_FOUND =
'shortcode/properties_not_found' as const; 'shortcode/properties_not_found' as const;
/**
* Infra Config not found
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const;
/**
* Infra Config update failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_UPDATE_FAILED = 'infra_config/update_failed' as const;
/**
* Infra Config not listed for onModuleInit creation
* (InfraConfigService)
*/
export const INFRA_CONFIG_NOT_LISTED =
'infra_config/properly_not_listed' as const;
/**
* Infra Config reset failed
* (InfraConfigService)
*/
export const INFRA_CONFIG_RESET_FAILED = 'infra_config/reset_failed' as const;
/**
* Infra Config invalid input for Config variable
* (InfraConfigService)
*/
export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
/**
* 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

@@ -1,44 +0,0 @@
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

@@ -1,29 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -1,109 +0,0 @@
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

@@ -1,312 +0,0 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InfraConfig } from './infra-config.model';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfig as DBInfraConfig } from '@prisma/client';
import * as E from 'fp-ts/Either';
import {
InfraConfigEnum,
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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
export enum InfraConfigEnum {
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
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,26 +131,6 @@ export const validateEmail = (email: string) => {
).test(email); ).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 * String to JSON parser
* @param {str} str The string to parse * @param {str} str The string to parse
@@ -181,23 +161,21 @@ export function isValidLength(title: string, length: number) {
/** /**
* This function is called by bootstrap() in main.ts * 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. * If not, it throws an error.
*/ */
export function checkEnvironmentAuthProvider( export function checkEnvironmentAuthProvider() {
VITE_ALLOWED_AUTH_PROVIDERS: string, if (!process.env.hasOwnProperty('VITE_ALLOWED_AUTH_PROVIDERS')) {
) {
if (!VITE_ALLOWED_AUTH_PROVIDERS) {
throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS); throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS);
} }
if (VITE_ALLOWED_AUTH_PROVIDERS === '') { if (process.env.VITE_ALLOWED_AUTH_PROVIDERS === '') {
throw new Error(ENV_EMPTY_AUTH_PROVIDERS); throw new Error(ENV_EMPTY_AUTH_PROVIDERS);
} }
const givenAuthProviders = VITE_ALLOWED_AUTH_PROVIDERS.split(',').map( const givenAuthProviders = process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(
(provider) => provider.toLocaleUpperCase(), ',',
); ).map((provider) => provider.toLocaleUpperCase());
const supportedAuthProviders = Object.values(AuthProvider).map( const supportedAuthProviders = Object.values(AuthProvider).map(
(provider: string) => provider.toLocaleUpperCase(), (provider: string) => provider.toLocaleUpperCase(),
); );

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ describe("Test 'hopp test <file>' command:", () => {
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR"); expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
}); });
test("Malformed collection file.", async () => { test("Malformed collection file.", async () => {
const cmd = `node ./bin/hopp test ${getTestJsonFilePath( const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
"malformed-collection2.json" "malformed-collection2.json"
@@ -106,7 +106,7 @@ describe("Test 'hopp test <file> --env <file>' command:", () => {
const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json"); const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json");
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json"); const ENV_PATH = getTestJsonFilePath("env-flag-envs.json");
const cmd = `node ./bin/hopp test ${TESTS_PATH} --env ${ENV_PATH}`; const cmd = `node ./bin/hopp test ${TESTS_PATH} --env ${ENV_PATH}`;
const { error, stdout } = await execAsync(cmd); const { error } = await execAsync(cmd);
expect(error).toBeNull(); expect(error).toBeNull();
}); });
@@ -129,6 +129,7 @@ describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`; const cmd = `${VALID_TEST_CMD} --delay 'NaN'`;
const { stderr } = await execAsync(cmd); const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr); const out = getErrorCode(stderr);
console.log("invalid value thing", out)
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT"); expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
}); });

View File

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

View File

@@ -12,7 +12,7 @@
"method": "POST", "method": "POST",
"auth": { "authType": "none", "authActive": true }, "auth": { "authType": "none", "authActive": true },
"preRequestScript": "", "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 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})", "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})",
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}" "body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}"

View File

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

View File

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

View File

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

View File

@@ -5,4 +5,5 @@ module.exports = {
printWidth: 80, printWidth: 80,
useTabs: false, useTabs: false,
tabWidth: 2, 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"><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" 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>

Before

Width:  |  Height:  |  Size: 1017 B

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 684 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 633 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 610 B

View File

@@ -29,6 +29,14 @@
@apply antialiased; @apply antialiased;
accent-color: var(--accent-color); accent-color: var(--accent-color);
font-variant-ligatures: common-ligatures; 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 { ::-webkit-scrollbar-track {
@@ -57,7 +65,7 @@ input::placeholder,
textarea::placeholder, textarea::placeholder,
.cm-placeholder { .cm-placeholder {
@apply text-secondary; @apply text-secondary;
@apply opacity-50 #{!important}; @apply opacity-50;
} }
input, input,
@@ -76,7 +84,7 @@ body {
@apply font-medium; @apply font-medium;
@apply select-none; @apply select-none;
@apply overflow-x-hidden; @apply overflow-x-hidden;
@apply leading-body #{!important}; @apply leading-body;
animation: fade 300ms forwards; animation: fade 300ms forwards;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none; -webkit-touch-callout: none;
@@ -174,7 +182,7 @@ a {
@apply font-semibold; @apply font-semibold;
@apply px-2 py-1; @apply px-2 py-1;
@apply truncate; @apply truncate;
@apply leading-body; @apply leading-normal;
@apply items-center; @apply items-center;
kbd { kbd {
@@ -221,7 +229,7 @@ a {
@apply overflow-y-auto; @apply overflow-y-auto;
@apply text-body text-secondary; @apply text-body text-secondary;
@apply p-2; @apply p-2;
@apply leading-body; @apply leading-normal;
@apply focus:outline-none; @apply focus:outline-none;
scroll-behavior: smooth; scroll-behavior: smooth;
@@ -253,7 +261,7 @@ a {
hr { hr {
@apply border-b border-dividerLight; @apply border-b border-dividerLight;
@apply my-2 #{!important}; @apply my-2;
} }
.heading { .heading {
@@ -342,28 +350,44 @@ 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 { .info-response {
color: var(--status-info-color); color: var(--info-color);
} }
.success-response { .success-response {
color: var(--status-success-color); color: var(--success-color);
} }
.redirect-response { .redir-response {
color: var(--status-redirect-color); color: var(--warning-color);
} }
.critical-error-response { .cl-error-response {
color: var(--status-critical-error-color); color: var(--cl-error-color);
} }
.server-error-response { .sv-error-response {
color: var(--status-server-error-color); color: var(--sv-error-color);
} }
.missing-data-response { .missing-data-response {
color: var(--status-missing-data-color); @apply text-secondaryLight;
} }
.toasted-container { .toasted-container {
@@ -513,12 +537,12 @@ pre.ace_editor {
@apply inline-flex; @apply inline-flex;
@apply font-sans; @apply font-sans;
@apply text-tiny; @apply text-tiny;
@apply bg-dividerLight; @apply bg-divider;
@apply rounded; @apply rounded;
@apply ml-2; @apply ml-2;
@apply px-1; @apply px-1;
@apply min-w-[1.25rem]; @apply min-w-5;
@apply min-h-[1.25rem]; @apply min-h-5;
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply border border-dividerDark; @apply border border-dividerDark;

View File

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

View File

@@ -1,16 +1,17 @@
@mixin base-theme { @mixin base-theme {
--font-sans: "Inter Variable", sans-serif; --font-sans: "Inter Variable", sans-serif;
--font-icon: "Material Symbols Rounded Variable";
--font-mono: "Roboto Mono Variable", monospace; --font-mono: "Roboto Mono Variable", monospace;
--font-size-body: 0.75rem; --font-size-body: 0.75rem;
--font-size-tiny: 0.625rem; --font-size-tiny: 0.688rem;
--line-height-body: 1rem; --line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem; --upper-primary-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem; --upper-secondary-sticky-fold: 6.188rem;
--upper-tertiary-sticky-fold: 8.25rem; --upper-tertiary-sticky-fold: 8.25rem;
--upper-fourth-sticky-fold: 10.2rem; --upper-fourth-sticky-fold: 10.2rem;
--upper-mobile-primary-sticky-fold: 6.75rem; --upper-mobile-primary-sticky-fold: 6.625rem;
--upper-mobile-secondary-sticky-fold: 8.813rem; --upper-mobile-secondary-sticky-fold: 8.688rem;
--upper-mobile-sticky-fold: 10.875rem; --upper-mobile-sticky-fold: 10.75rem;
--upper-mobile-tertiary-sticky-fold: 8.25rem; --upper-mobile-tertiary-sticky-fold: 8.25rem;
--lower-primary-sticky-fold: 3rem; --lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5.063rem; --lower-secondary-sticky-fold: 5.063rem;
@@ -19,122 +20,62 @@
--sidebar-primary-sticky-fold: 2rem; --sidebar-primary-sticky-fold: 2rem;
} }
@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 { @mixin dark-theme {
--primary-color: #181818; --primary-color: #181818;
--primary-light-color: #1c1c1e; --primary-light-color: #1c1c1e;
--primary-dark-color: theme("colors.neutral.800"); --primary-dark-color: #262626;
--primary-contrast-color: theme("colors.neutral.900"); --primary-contrast-color: #171717;
--secondary-color: theme("colors.neutral.400"); --secondary-color: #a3a3a3;
--secondary-light-color: theme("colors.neutral.500"); --secondary-light-color: #737373;
--secondary-dark-color: theme("colors.zinc.50"); --secondary-dark-color: #fafafa;
--divider-color: #1f1f1f; --divider-color: #262626;
--divider-light-color: #1f1f1f; --divider-light-color: #1f1f1f;
--divider-dark-color: theme("colors.zinc.800"); --divider-dark-color: #2d2d2d;
--banner-info-color: theme("colors.stone.800"); --error-color: #292524;
--banner-warning-color: theme("colors.yellow.800"); --tooltip-color: #f5f5f5;
--banner-error-color: theme("colors.red.800");
--tooltip-color: theme("colors.neutral.100");
--popover-color: #1b1b1b; --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"; --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 { @mixin black-theme {
--primary-color: #0f0f0f; --primary-color: #0f0f0f;
--primary-light-color: theme("colors.neutral.900"); --primary-light-color: #171717;
--primary-dark-color: #181818; --primary-dark-color: #181818;
--primary-contrast-color: #0f0f0f; --primary-contrast-color: #0f0f0f;
--secondary-color: theme("colors.neutral.400"); --secondary-color: #a3a3a3;
--secondary-light-color: theme("colors.neutral.500"); --secondary-light-color: #737373;
--secondary-dark-color: theme("colors.neutral.50"); --secondary-dark-color: #f5f5f5;
--divider-color: theme("colors.neutral.900"); --divider-color: #1c1c1e;
--divider-light-color: theme("colors.neutral.900"); --divider-light-color: #181818;
--divider-dark-color: theme("colors.zinc.800"); --divider-dark-color: #323232;
--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"; --editor-theme: "twilight";
} }

View File

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

View File

@@ -11,7 +11,6 @@
"connect": "Connect", "connect": "Connect",
"connecting": "Connecting", "connecting": "Connecting",
"copy": "Copy", "copy": "Copy",
"create": "Create",
"delete": "Delete", "delete": "Delete",
"disconnect": "Disconnect", "disconnect": "Disconnect",
"dismiss": "Dismiss", "dismiss": "Dismiss",
@@ -41,7 +40,6 @@
"scroll_to_top": "Scroll to top", "scroll_to_top": "Scroll to top",
"search": "Search", "search": "Search",
"send": "Send", "send": "Send",
"share": "Share",
"start": "Start", "start": "Start",
"starting": "Starting", "starting": "Starting",
"stop": "Stop", "stop": "Stop",
@@ -80,7 +78,6 @@
"contact_us": "Contact us", "contact_us": "Contact us",
"cookies": "Cookies", "cookies": "Cookies",
"copy": "Copy", "copy": "Copy",
"copy_interface_type": "Copy interface type",
"copy_user_id": "Copy User Auth Token", "copy_user_id": "Copy User Auth Token",
"developer_option": "Developer options", "developer_option": "Developer options",
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.",
@@ -96,7 +93,6 @@
"keyboard_shortcuts": "Keyboard shortcuts", "keyboard_shortcuts": "Keyboard shortcuts",
"name": "Hoppscotch", "name": "Hoppscotch",
"new_version_found": "New version found. Refresh to update.", "new_version_found": "New version found. Refresh to update.",
"open_in_hoppscotch": "Open in Hoppscotch",
"options": "Options", "options": "Options",
"proxy_privacy_policy": "Proxy privacy policy", "proxy_privacy_policy": "Proxy privacy policy",
"reload": "Reload", "reload": "Reload",
@@ -191,7 +187,6 @@
"remove_folder": "Are you sure you want to permanently delete this folder?", "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_history": "Are you sure you want to permanently delete all history?",
"remove_request": "Are you sure you want to permanently delete this request?", "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_team": "Are you sure you want to delete this team?",
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?", "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.", "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
@@ -233,8 +228,7 @@
"profile": "Login to view your profile", "profile": "Login to view your profile",
"protocols": "Protocols are empty", "protocols": "Protocols are empty",
"schema": "Connect to a GraphQL endpoint to view schema", "schema": "Connect to a GraphQL endpoint to view schema",
"shared_requests_logout": "Login to view your shared requests or create a new one", "shortcodes": "Shortcodes are empty",
"shared_requests": "Shared requests are empty",
"subscription": "Subscriptions are empty", "subscription": "Subscriptions are empty",
"team_name": "Team name empty", "team_name": "Team name empty",
"teams": "You don't belong to any teams", "teams": "You don't belong to any teams",
@@ -274,9 +268,6 @@
"variable": "Variable", "variable": "Variable",
"variable_list": "Variable List" "variable_list": "Variable List"
}, },
"graphql_collections": {
"title": "GraphQL Collections"
},
"error": { "error": {
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.", "browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.", "check_console_details": "Check console log for details.",
@@ -312,8 +303,7 @@
"create_secret_gist": "Create secret Gist", "create_secret_gist": "Create secret Gist",
"gist_created": "Gist created", "gist_created": "Gist created",
"require_github": "Login with GitHub to create secret gist", "require_github": "Login with GitHub to create secret gist",
"title": "Export", "title": "Export"
"failed": "Something went wrong while exporting"
}, },
"filter": { "filter": {
"all": "All", "all": "All",
@@ -350,8 +340,8 @@
"authorization": "The authorization header will be automatically generated when you send the request.", "authorization": "The authorization header will be automatically generated when you send the request.",
"generate_documentation_first": "Generate documentation first", "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.", "network_fail": "Unable to reach the API endpoint. Check your network connection or select a different Interceptor and try again.",
"offline": "You're using Hoppscotch offline. Updates will sync when you're online, based on workspace settings.", "offline": "You seem to be offline. Data in this workspace might not be up to date.",
"offline_short": "You're using Hoppscotch offline.", "offline_short": "You seem to be offline.",
"post_request_tests": "Test scripts are written in JavaScript, and are run after the response is received.", "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.", "pre_request_script": "Pre-request scripts are written in JavaScript, and are run before the request is sent.",
"script_fail": "It seems there is a glitch in the pre-request script. Check the error below and fix the script accordingly.", "script_fail": "It seems there is a glitch in the pre-request script. Check the error below and fix the script accordingly.",
@@ -380,7 +370,6 @@
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)",
"from_postman": "Import from Postman", "from_postman": "Import from Postman",
"from_postman_description": "Import from Postman collection", "from_postman_description": "Import from Postman collection",
"from_file": "Import from File",
"from_url": "Import from URL", "from_url": "Import from URL",
"gist_url": "Enter Gist URL", "gist_url": "Enter Gist URL",
"import_from_url_invalid_fetch": "Couldn't get data from the url", "import_from_url_invalid_fetch": "Couldn't get data from the url",
@@ -388,14 +377,7 @@
"import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'",
"import_from_url_success": "Collections Imported", "import_from_url_success": "Collections Imported",
"json_description": "Import collections from a Hoppscotch Collections JSON file", "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 JSON 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": { "inspections": {
"description": "Inspect possible errors", "description": "Inspect possible errors",
@@ -432,9 +414,7 @@
"close_unsaved_tab": "You have unsaved changes", "close_unsaved_tab": "You have unsaved changes",
"collections": "Collections", "collections": "Collections",
"confirm": "Confirm", "confirm": "Confirm",
"customize_request": "Customize Request",
"edit_request": "Edit Request", "edit_request": "Edit Request",
"share_request": "Share Request",
"import_export": "Import / Export" "import_export": "Import / Export"
}, },
"mqtt": { "mqtt": {
@@ -510,6 +490,7 @@
"structured": "Structured", "structured": "Structured",
"text": "Text" "text": "Text"
}, },
"copy_link": "Copy link",
"different_collection": "Cannot reorder requests from different collections", "different_collection": "Cannot reorder requests from different collections",
"duplicated": "Request duplicated", "duplicated": "Request duplicated",
"duration": "Duration", "duration": "Duration",
@@ -542,7 +523,6 @@
"saved": "Request saved", "saved": "Request saved",
"share": "Share", "share": "Share",
"share_description": "Share Hoppscotch with your friends", "share_description": "Share Hoppscotch with your friends",
"share_request": "Share Request",
"stop": "Stop", "stop": "Stop",
"title": "Request", "title": "Request",
"type": "Request type", "type": "Request type",
@@ -623,31 +603,14 @@
"additional": "Additional Settings", "additional": "Additional Settings",
"verify_email": "Verify email" "verify_email": "Verify email"
}, },
"shared_requests": { "shortcodes": {
"button": "Button", "actions": "Actions",
"button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", "created_on": "Created on",
"customize": "Customize", "deleted": "Shortcode deleted",
"creating_widget": "Creating widget", "method": "Method",
"copy_html": "Copy HTML", "not_found": "Shortcode not found",
"copy_link": "Copy Link", "short_code": "Short code",
"copy_markdown": "Copy Markdown", "url": "URL"
"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": { "shortcut": {
"general": { "general": {
@@ -677,6 +640,7 @@
"title": "Others" "title": "Others"
}, },
"request": { "request": {
"copy_request_link": "Copy Request Link",
"delete_method": "Select DELETE method", "delete_method": "Select DELETE method",
"get_method": "Select GET method", "get_method": "Select GET method",
"head_method": "Select HEAD method", "head_method": "Select HEAD method",
@@ -692,7 +656,6 @@
"save_to_collections": "Save to Collections", "save_to_collections": "Save to Collections",
"send_request": "Send Request", "send_request": "Send Request",
"show_code": "Generate code snippet", "show_code": "Generate code snippet",
"share_request": "Share Request",
"title": "Request" "title": "Request"
}, },
"response": { "response": {
@@ -817,7 +780,6 @@
"connection_failed": "Connection failed", "connection_failed": "Connection failed",
"connection_lost": "Connection lost", "connection_lost": "Connection lost",
"copied_to_clipboard": "Copied to clipboard", "copied_to_clipboard": "Copied to clipboard",
"copied_interface_to_clipboard": "Copied {language} interface type to clipboard",
"deleted": "Deleted", "deleted": "Deleted",
"deprecated": "DEPRECATED", "deprecated": "DEPRECATED",
"disabled": "Disabled", "disabled": "Disabled",
@@ -876,7 +838,6 @@
"queries": "Queries", "queries": "Queries",
"query": "Query", "query": "Query",
"schema": "Schema", "schema": "Schema",
"shared_requests": "Shared Requests",
"socketio": "Socket.IO", "socketio": "Socket.IO",
"sse": "SSE", "sse": "SSE",
"tests": "Tests", "tests": "Tests",

View File

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

View File

@@ -61,7 +61,6 @@ declare module 'vue' {
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default'] CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default'] CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
CookiesEditCookie: typeof import('./components/cookies/EditCookie.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'] Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default'] EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default'] EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
@@ -109,7 +108,6 @@ declare module 'vue' {
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing'] HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio'] HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup'] HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver'] HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
@@ -162,14 +160,6 @@ declare module 'vue' {
IconLucideRss: typeof import('~icons/lucide/rss')['default'] IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['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'] InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default'] InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
@@ -193,16 +183,6 @@ declare module 'vue' {
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default'] RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default'] SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.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'] SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default'] SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default'] SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
@@ -223,7 +203,6 @@ declare module 'vue' {
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default'] SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default'] SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.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'] SmartSlideOver: typeof import('./../../hoppscotch-ui/src/components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./../../hoppscotch-ui/src/components/smart/Spinner.vue')['default'] SmartSpinner: typeof import('./../../hoppscotch-ui/src/components/smart/Spinner.vue')['default']
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default'] SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']

View File

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

View File

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

View File

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

View File

@@ -17,10 +17,9 @@
v-if="isEmpty(shortcutsResults)" v-if="isEmpty(shortcutsResults)"
:text="`${t('state.nothing_found')} ‟${filterText}”`" :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>
<details <details
v-for="(sectionResults, sectionTitle) in shortcutsResults" v-for="(sectionResults, sectionTitle) in shortcutsResults"
v-else v-else

View File

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

View File

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

View File

@@ -14,14 +14,14 @@
></div> ></div>
<div class="relative flex flex-col"> <div class="relative flex flex-col">
<div <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="{ :class="{
'opacity-25': 'opacity-25':
dragging && notSameDestination && notSameParentDestination, dragging && notSameDestination && notSameParentDestination,
}" }"
></div> ></div>
<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" :draggable="!hasNoTeamAccess"
@dragstart="dragStart" @dragstart="dragStart"
@drop="handelDrop($event)" @drop="handelDrop($event)"
@@ -290,13 +290,13 @@ const collectionIcon = computed(() => {
if (props.isSelected) return IconCheckCircle if (props.isSelected) return IconCheckCircle
else if (!props.isOpen) return IconFolder else if (!props.isOpen) return IconFolder
else if (props.isOpen) return IconFolderOpen else if (props.isOpen) return IconFolderOpen
return IconFolder else return IconFolder
}) })
const collectionName = computed(() => { const collectionName = computed(() => {
if ((props.data as HoppCollection<HoppRESTRequest>).name) if ((props.data as HoppCollection<HoppRESTRequest>).name)
return (props.data as HoppCollection<HoppRESTRequest>).name return (props.data as HoppCollection<HoppRESTRequest>).name
return (props.data as TeamCollection).title else return (props.data as TeamCollection).title
}) })
watch( watch(
@@ -424,8 +424,9 @@ const isCollLoading = computed(() => {
props.data.id props.data.id
) { ) {
return collectionMoveLoading.includes(props.data.id) return collectionMoveLoading.includes(props.data.id)
} else {
return false
} }
return false
}) })
const resetDragState = () => { const resetDragState = () => {

View File

@@ -1,568 +1,361 @@
<template> <template>
<ImportExportBase <HoppSmartModal
ref="collections-import-export" v-if="show"
modal-title="modal.collections" dialog
:importer-modules="importerModules" :title="t('modal.collections')"
:exporter-modules="exporterModules" styles="sm:max-w-md"
@hide-modal="emit('hide-modal')" @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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import * as E from "fp-ts/Either" import IconArrowLeft from "~icons/lucide/arrow-left"
import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource" import IconDownload from "~icons/lucide/download"
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 { HoppRESTRequest } from "@hoppscotch/data"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
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 IconGithub from "~icons/lucide/github"
import IconLink from "~icons/lucide/link" import { computed, PropType, ref, watch } from "vue"
import { pipe } from "fp-ts/function"
import IconUser from "~icons/lucide/user" import * as E from "fp-ts/Either"
import { useReadonlyStream } from "~/composables/stream" import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { getTeamCollectionJSON } from "~/helpers/backend/helpers" import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { platform } from "~/platform" import { platform } from "~/platform"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
import { StepReturnValue } from "~/helpers/import-export/steps"
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 t = useI18n()
const toast = useToast() const toast = useToast()
const t = useI18n()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined type CollectionType = "team-collections" | "my-collections"
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections" }
const props = defineProps({ const props = defineProps({
collectionsType: { show: {
type: Object as PropType<CollectionType>, type: Boolean,
default: () => ({ default: false,
type: "my-collections",
selectedTeam: undefined,
}),
required: true, required: true,
}, },
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
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( const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(), platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser() platform.auth.getCurrentUser()
) )
const showImportFailedError = () => { const importerAction = async (stepResults: StepReturnValue[]) => {
toast.error(t("import.failed")) if (!importerModule.value) return
}
const handleImportToStore = async ( pipe(
collections: HoppCollection<HoppRESTRequest>[] await importerModule.value.importer(stepResults as any)(),
) => { E.match(
const importResult = (err) => {
props.collectionsType.type === "my-collections" failedImport()
? await importToPersonalWorkspace(collections) console.error("error", err)
: await importToTeamsWorkspace(collections) },
(result) => {
if (props.collectionsType === "team-collections") {
emit("import-to-teams", result)
} else {
appendRESTCollections(result)
if (E.isRight(importResult)) { platform.analytics?.logEvent({
toast.success(t("state.file_imported")) type: "HOPP_IMPORT_COLLECTION",
emit("hide-modal") importer: importerModule.value!.name,
} else { platform: "rest",
toast.error(t("import.failed")) workspaceType: "personal",
} })
}
const importToPersonalWorkspace = ( fileImported()
collections: HoppCollection<HoppRESTRequest>[]
) => {
appendRESTCollections(collections)
return E.right({
success: true,
})
}
const importToTeamsWorkspace = async (
collections: HoppCollection<HoppRESTRequest>[]
) => {
if (!hasTeamWriteAccess.value || !selectedTeamID.value) {
return E.left({
success: false,
})
}
const res = await toTeamsImporter(
JSON.stringify(collections),
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 = { const finishImport = async () => {
metadata: { await importerAction(stepResults.value)
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 onFileChange = () => {
const accessToken = currentUser.value?.accessToken stepResults.value = []
if (!accessToken) { const inputFileToImport = inputChooseFileToImportFrom.value[0]
toast.error(t("error.something_went_wrong"))
isHoppGistCollectionExporterInProgress.value = false if (!inputFileToImport) {
hasFile.value = false
return
}
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
inputChooseFileToImportFrom.value[0].value = ""
hasFile.value = false
toast.show(t("action.choose_file").toString())
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
hasFile.value = false
toast.show(t("action.choose_file").toString())
return return
} }
if (E.isRight(collectionJSON)) { stepResults.value.push(content)
collectionsGistExporter(collectionJSON.right, accessToken) hasFile.value = !!content?.length
}
platform.analytics?.logEvent({ reader.readAsText(inputFileToImport.files[0])
type: "HOPP_EXPORT_COLLECTION",
exporter: "gist",
platform: "rest",
})
}
isHoppGistCollectionExporterInProgress.value = false
},
} }
const importerModules = computed(() => { const fileImported = () => {
const enabledImporters = [ toast.success(t("state.file_imported").toString())
HoppRESTImporter, hideModal()
HoppMyCollectionImporter, }
HoppOpenAPIImporter, const failedImport = () => {
HoppPostmanImporter, toast.error(t("import.failed").toString())
HoppInsomniaImporter, }
HoppGistImporter, const hideModal = () => {
] resetImport()
emit("hide-modal")
}
const isTeams = props.collectionsType.type === "team-collections" const resetImport = () => {
importerType.value = null
return enabledImporters.filter((importer) => { hasFile.value = false
return isTeams hasGist.value = false
? importer.metadata.applicableTo.includes("team-workspace") stepResults.value = []
: importer.metadata.applicableTo.includes("personal-workspace") inputChooseFileToImportFrom.value = ""
}) inputChooseGistToImportFrom.value = ""
}) mySelectedCollectionID.value = undefined
const exporterModules = computed(() => {
const enabledExporters = [
HoppMyCollectionsExporter,
HoppTeamCollectionsExporter,
]
if (platform.platformFeatureFlags.exportAsGIST) {
enabledExporters.push(HoppGistCollectionsExporter)
}
return enabledExporters.filter((exporter) => {
return exporter.metadata.applicableTo.includes(
props.collectionsType.type === "my-collections"
? "personal-workspace"
: "team-workspace"
)
})
})
const hasTeamWriteAccess = computed(() => {
const { collectionsType } = props
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> </script>

View File

@@ -222,12 +222,6 @@
requestIndex: pathToIndex(node.id), requestIndex: pathToIndex(node.id),
}) })
" "
@share-request="
node.data.type === 'requests' &&
emit('share-request', {
request: node.data.data.data,
})
"
@drag-request=" @drag-request="
dragRequest($event, { dragRequest($event, {
folderPath: node.data.data.parentIndex, folderPath: node.data.data.parentIndex,
@@ -254,7 +248,7 @@
:text="`${t('state.nothing_found')}${filterText}`" :text="`${t('state.nothing_found')}${filterText}`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="svg-icons opacity-75" /> <icon-lucide-search class="svg-icons pb-2 opacity-75" />
</template> </template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
@@ -263,29 +257,27 @@
:alt="`${t('empty.collections')}`" :alt="`${t('empty.collections')}`"
:text="t('empty.collections')" :text="t('empty.collections')"
> >
<template #body> <div class="flex flex-col items-center space-y-4">
<div class="flex flex-col items-center space-y-4"> <span class="text-center text-secondaryLight">
<span class="text-center text-secondaryLight"> {{ t("collection.import_or_create") }}
{{ t("collection.import_or_create") }} </span>
</span> <div class="flex flex-col items-stretch gap-4">
<div class="flex flex-col items-stretch gap-4"> <HoppButtonPrimary
<HoppButtonPrimary :icon="IconImport"
:icon="IconImport" :label="t('import.title')"
:label="t('import.title')" filled
filled outline
outline @click="emit('display-modal-import-export')"
@click="emit('display-modal-import-export')" />
/> <HoppButtonSecondary
<HoppButtonSecondary :icon="IconPlus"
:icon="IconPlus" :label="t('add.new')"
:label="t('add.new')" filled
filled outline
outline @click="emit('display-modal-add')"
@click="emit('display-modal-add')" />
/>
</div>
</div> </div>
</template> </div>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'" v-else-if="node.data.type === 'collections'"
@@ -293,20 +285,18 @@
:alt="`${t('empty.collections')}`" :alt="`${t('empty.collections')}`"
:text="t('empty.collections')" :text="t('empty.collections')"
> >
<template #body> <HoppButtonSecondary
<HoppButtonSecondary :label="t('add.new')"
:label="t('add.new')" filled
filled outline
outline @click="
@click=" node.data.type === 'collections' &&
node.data.type === 'collections' && emit('add-folder', {
emit('add-folder', { path: node.id,
path: node.id, folder: node.data.data.data,
folder: node.data.data.data, })
}) "
" />
/>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'" v-else-if="node.data.type === 'folders'"
@@ -470,12 +460,6 @@ const emit = defineEmits<{
isActive: boolean isActive: boolean
} }
): void ): void
(
event: "share-request",
payload: {
request: HoppRESTRequest
}
): void
( (
event: "drop-request", event: "drop-request",
payload: { payload: {
@@ -542,12 +526,13 @@ const isSelected = ({
props.picked.folderPath === folderPath && props.picked.folderPath === folderPath &&
props.picked.requestIndex === requestIndex 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) const tabs = useService(RESTTabService)
@@ -744,10 +729,11 @@ class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
status: "loaded", status: "loaded",
data: data, data: data,
} as ChildrenResult<Folder | Requests> } as ChildrenResult<Folder | Requests>
} } else {
return { return {
status: "loaded", status: "loaded",
data: [], data: [],
}
} }
}) })
} }

View File

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

View File

@@ -141,8 +141,9 @@ const reqName = computed(() => {
return props.request.name return props.request.name
} else if (props.mode === "rest") { } else if (props.mode === "rest") {
return restRequestName.value return restRequestName.value
} else {
return gqlRequestName.value
} }
return gqlRequestName.value
}) })
const requestName = ref(reqName.value) const requestName = ref(reqName.value)
@@ -479,20 +480,21 @@ const getErrorMessage = (err: GQLError<string>) => {
console.error(err) console.error(err)
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} } else {
switch (err.error) { switch (err.error) {
case "team_coll/short_title": case "team_coll/short_title":
return t("collection.name_length_insufficient") return t("collection.name_length_insufficient")
case "team/invalid_coll_id": case "team/invalid_coll_id":
return t("team.invalid_id") return t("team.invalid_id")
case "team/not_required_role": case "team/not_required_role":
return t("profile.no_permission") return t("profile.no_permission")
case "team_req/not_required_role": case "team_req/not_required_role":
return t("profile.no_permission") return t("profile.no_permission")
case "Forbidden resource": case "Forbidden resource":
return t("profile.no_permission") return t("profile.no_permission")
default: default:
return t("error.something_went_wrong") return t("error.something_went_wrong")
}
} }
} }
</script> </script>

View File

@@ -15,12 +15,12 @@
class="!rounded-none" class="!rounded-none"
:icon="IconPlus" :icon="IconPlus"
:title="t('team.no_access')" :title="t('team.no_access')"
:label="t('action.new')" :label="t('add.new')"
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-else v-else
:icon="IconPlus" :icon="IconPlus"
:label="t('action.new')" :label="t('add.new')"
class="!rounded-none" class="!rounded-none"
@click="emit('display-modal-add')" @click="emit('display-modal-add')"
/> />
@@ -240,12 +240,6 @@
requestIndex: node.data.data.data.id, requestIndex: node.data.data.data.id,
}) })
" "
@share-request="
node.data.type === 'requests' &&
emit('share-request', {
request: node.data.data.data.request,
})
"
@drag-request=" @drag-request="
dragRequest($event, { dragRequest($event, {
folderPath: node.data.data.parentIndex, folderPath: node.data.data.parentIndex,
@@ -274,37 +268,33 @@
:text="t('empty.collections')" :text="t('empty.collections')"
@drop.stop @drop.stop
> >
<template #body> <div class="flex flex-col items-center space-y-4">
<div class="flex flex-col items-center space-y-4"> <span class="text-center text-secondaryLight">
<span class="text-center text-secondaryLight"> {{ t("collection.import_or_create") }}
{{ t("collection.import_or_create") }} </span>
</span> <div class="flex flex-col items-stretch gap-4">
<div class="flex flex-col items-stretch gap-4"> <HoppButtonPrimary
<HoppButtonPrimary :icon="IconImport"
:icon="IconImport" :label="t('import.title')"
:label="t('import.title')" filled
filled outline
outline :disabled="hasNoTeamAccess"
:disabled="hasNoTeamAccess" :title="hasNoTeamAccess ? t('team.no_access') : ''"
:title="hasNoTeamAccess ? t('team.no_access') : ''" @click="
@click=" hasNoTeamAccess ? null : emit('display-modal-import-export')
hasNoTeamAccess "
? null />
: emit('display-modal-import-export') <HoppButtonSecondary
" :icon="IconPlus"
/> :label="t('add.new')"
<HoppButtonSecondary filled
:icon="IconPlus" outline
:label="t('add.new')" :disabled="hasNoTeamAccess"
filled :title="hasNoTeamAccess ? t('team.no_access') : ''"
outline @click="hasNoTeamAccess ? null : emit('display-modal-add')"
:disabled="hasNoTeamAccess" />
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="hasNoTeamAccess ? null : emit('display-modal-add')"
/>
</div>
</div> </div>
</template> </div>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'" v-else-if="node.data.type === 'collections'"
@@ -313,20 +303,18 @@
:text="t('empty.collections')" :text="t('empty.collections')"
@drop.stop @drop.stop
> >
<template #body> <HoppButtonSecondary
<HoppButtonSecondary :label="t('add.new')"
:label="t('add.new')" filled
filled outline
outline @click="
@click=" node.data.type === 'collections' &&
node.data.type === 'collections' && emit('add-folder', {
emit('add-folder', { path: node.id,
path: node.id, folder: node.data.data.data,
folder: node.data.data.data, })
}) "
" />
/>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'" v-else-if="node.data.type === 'folders'"
@@ -485,12 +473,6 @@ const emit = defineEmits<{
folderPath?: string | undefined folderPath?: string | undefined
} }
): void ): void
(
event: "share-request",
payload: {
request: HoppRESTRequest
}
): void
( (
event: "drop-request", event: "drop-request",
payload: { payload: {
@@ -560,12 +542,13 @@ const isSelected = ({
props.picked.pickedType === "teams-request" && props.picked.pickedType === "teams-request" &&
props.picked.requestID === requestID 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) const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
@@ -731,77 +714,81 @@ class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
return { return {
status: "loading", status: "loading",
} }
} } else {
const data = this.data.value.map((item, index) => ({ const data = this.data.value.map((item, index) => ({
id: item.id, id: item.id,
data: {
isLastItem: index === this.data.value.length - 1,
type: "collections",
data: { data: {
parentIndex: null, isLastItem: index === this.data.value.length - 1,
data: item, type: "collections",
data: {
parentIndex: null,
data: item,
},
}, },
}, }))
})) return {
return { status: "loaded",
status: "loaded", data: cloneDeep(data),
data: cloneDeep(data), } as ChildrenResult<TeamCollections>
} as ChildrenResult<TeamCollections> }
} } else {
const parsedID = id.split("/")[id.split("/").length - 1] const parsedID = id.split("/")[id.split("/").length - 1]
!props.teamLoadingCollections.includes(parsedID) && !props.teamLoadingCollections.includes(parsedID) &&
emit("expand-team-collection", parsedID) emit("expand-team-collection", parsedID)
if (props.teamLoadingCollections.includes(parsedID)) { if (props.teamLoadingCollections.includes(parsedID)) {
return { return {
status: "loading", 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: [],
}
}
} }
}
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

@@ -180,18 +180,16 @@
:alt="`${t('empty.collection')}`" :alt="`${t('empty.collection')}`"
:text="t('empty.collection')" :text="t('empty.collection')"
> >
<template #body> <HoppButtonSecondary
<HoppButtonSecondary :label="t('add.new')"
:label="t('add.new')" filled
filled outline
outline @click="
@click=" emit('add-folder', {
emit('add-folder', { path: `${collectionIndex}`,
path: `${collectionIndex}`, })
}) "
" />
/>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
@@ -273,7 +271,7 @@ const collectionIcon = computed(() => {
if (isSelected.value) return IconCheckCircle if (isSelected.value) return IconCheckCircle
else if (!showChildren.value && !props.isFiltered) return IconFolder else if (!showChildren.value && !props.isFiltered) return IconFolder
else if (!showChildren.value || props.isFiltered) return IconFolderOpen else if (!showChildren.value || props.isFiltered) return IconFolderOpen
return IconFolder else return IconFolder
}) })
const pick = () => { const pick = () => {

View File

@@ -176,7 +176,8 @@
:src="`/images/states/${colorMode.value}/pack.svg`" :src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`" :alt="`${t('empty.folder')}`"
:text="t('empty.folder')" :text="t('empty.folder')"
/> >
</HoppSmartPlaceholder>
</div> </div>
</div> </div>
<HoppSmartConfirmModal <HoppSmartConfirmModal
@@ -252,7 +253,7 @@ const collectionIcon = computed(() => {
if (isSelected.value) return IconCheckCircle if (isSelected.value) return IconCheckCircle
else if (!showChildren.value && !props.isFiltered) return IconFolder else if (!showChildren.value && !props.isFiltered) return IconFolder
else if (showChildren.value || !props.isFiltered) return IconFolderOpen else if (showChildren.value || !props.isFiltered) return IconFolderOpen
return IconFolder else return IconFolder
}) })
const pick = () => { const pick = () => {

View File

@@ -1,227 +1,299 @@
<template> <template>
<ImportExportBase <HoppSmartModal
ref="collections-import-export" v-if="show"
modal-title="graphql_collections.title" dialog
:importer-modules="importerModules" :title="`${t('modal.collections')}`"
:exporter-modules="exporterModules" styles="sm:max-w-md"
@hide-modal="emit('hide-modal')" @close="hideModal"
/> >
<template #actions>
<span>
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
:on-shown="() => tippyActions.focus()"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconGithub"
:label="t('import.from_gist')"
@click="
() => {
readCollectionGist()
hide()
}
"
/>
<span
v-tippy="{ theme: 'tooltip' }"
:title="
!currentUser
? `${t('export.require_github')}`
: currentUser.provider !== 'github.com'
? `${t('export.require_github')}`
: undefined
"
>
<HoppSmartItem
:disabled="
!currentUser
? true
: currentUser.provider !== 'github.com'
? true
: false
"
:icon="IconGithub"
:label="t('export.create_secret_gist')"
@click="
() => {
createCollectionGist()
hide()
}
"
/>
</span>
</div>
</template>
</tippy>
</span>
</template>
<template #body>
<div class="flex flex-col space-y-2">
<HoppSmartItem
:icon="IconFolderPlus"
:label="t('import.from_json')"
@click="openDialogChooseFileToImportFrom"
/>
<input
ref="inputChooseFileToImportFrom"
class="input"
type="file"
accept="application/json"
@change="importFromJSON"
/>
<hr />
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:label="t('export.as_json')"
@click="exportJSON"
/>
</div>
</template>
</HoppSmartModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "~/composables/i18n" import axios from "axios"
import { useToast } from "~/composables/toast" import IconMoreVertical from "~icons/lucide/more-vertical"
import { HoppCollection, HoppGQLRequest } from "@hoppscotch/data"
import { ImporterOrExporter } from "~/components/importExport/types"
import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import * as E from "fp-ts/Either"
import IconFolderPlus from "~icons/lucide/folder-plus" import IconFolderPlus from "~icons/lucide/folder-plus"
import IconUser from "~icons/lucide/user" import IconDownload from "~icons/lucide/download"
import { initializeDownloadCollection } from "~/helpers/import-export/export" import IconGithub from "~icons/lucide/github"
import { useReadonlyStream } from "~/composables/stream" import { computed, ref } from "vue"
import { platform } from "~/platform" import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { import {
graphqlCollections$, graphqlCollections$,
setGraphqlCollections, setGraphqlCollections,
appendGraphqlCollections,
} from "~/newstore/collections" } from "~/newstore/collections"
import { hoppGqlCollectionsImporter } from "~/helpers/import-export/import/hoppGql"
import { gqlCollectionsExporter } from "~/helpers/import-export/export/gqlCollections"
import { gqlCollectionsGistExporter } from "~/helpers/import-export/export/gqlCollectionsGistExporter"
import { computed } from "vue"
const t = useI18n() defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const toast = useToast() const toast = useToast()
const t = useI18n()
const collections = useReadonlyStream(graphqlCollections$, [])
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(), platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser() platform.auth.getCurrentUser()
) )
const GqlCollectionsHoppImporter: ImporterOrExporter = { // Template refs
metadata: { const tippyActions = ref<any | null>(null)
id: "import.from_json", const inputChooseFileToImportFrom = ref<HTMLInputElement>()
name: "import.from_json",
icon: IconFolderPlus,
title: "import.from_json",
applicableTo: ["personal-workspace"],
disabled: false,
},
component: FileSource({
acceptedFileTypes: "application/json",
caption: "import.from_json_description",
onImportFromFile: async (gqlCollections) => {
const res = await hoppGqlCollectionsImporter(gqlCollections)
if (E.isLeft(res)) { const collectionJson = computed(() => {
showImportFailedError() return JSON.stringify(collections.value, null, 2)
return
}
handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
platform: "gql",
workspaceType: "personal",
importer: "json",
})
emit("hide-modal")
},
}),
}
const GqlCollectionsGistImporter: ImporterOrExporter = {
metadata: {
id: "import.from_gist",
name: "import.from_gist",
icon: IconFolderPlus,
title: "import.from_gist",
applicableTo: ["personal-workspace", "team-workspace"],
disabled: false,
},
component: GistSource({
caption: "import.gql_collections_from_gist_description",
onImportFromGist: async (gqlCollections) => {
if (E.isLeft(gqlCollections)) {
showImportFailedError()
return
}
const res = await hoppGqlCollectionsImporter(gqlCollections.right)
if (E.isLeft(res)) {
showImportFailedError()
return
}
handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
platform: "gql",
workspaceType: "personal",
importer: "gist",
})
emit("hide-modal")
},
}),
}
const gqlCollections = useReadonlyStream(graphqlCollections$, [])
const GqlCollectionsHoppExporter: ImporterOrExporter = {
metadata: {
id: "export.as_json",
name: "export.as_json",
title: "action.download_file",
icon: IconUser,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace"],
},
action: () => {
if (!gqlCollections.value.length) {
return toast.error(t("error.no_collections_to_export"))
}
const message = initializeDownloadCollection(
gqlCollectionsExporter(gqlCollections.value),
"GQLCollections"
)
if (E.isLeft(message)) {
toast.error(t("export.failed"))
return
}
toast.success(message.right)
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
platform: "gql",
exporter: "json",
})
},
}
const GqlCollectionsGistExporter: ImporterOrExporter = {
metadata: {
id: "export.as_gist",
name: "export.create_secret_gist",
title: !currentUser
? "export.require_github"
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
currentUser.provider !== "github.com"
? `export.require_github`
: "export.create_secret_gist",
icon: IconUser,
disabled: !currentUser.value
? true
: currentUser.value.provider !== "github.com",
applicableTo: ["personal-workspace"],
},
action: async () => {
if (!currentUser.value) {
toast.error(t("profile.no_permission"))
return
}
const accessToken = currentUser.value?.accessToken
if (accessToken) {
const res = await gqlCollectionsGistExporter(
JSON.stringify(gqlCollections.value),
accessToken
)
if (E.isLeft(res)) {
toast.error(t("export.failed"))
return
}
toast.success(t("export.success"))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
platform: "gql",
exporter: "gist",
})
window.open(res.right, "_blank")
}
},
}
const importerModules = [GqlCollectionsHoppImporter, GqlCollectionsGistImporter]
const exporterModules = computed(() => {
const modules = [GqlCollectionsHoppExporter]
if (platform.platformFeatureFlags.exportAsGIST) {
modules.push(GqlCollectionsGistExporter)
}
return modules
}) })
const showImportFailedError = () => { const createCollectionGist = async () => {
toast.error(t("import.failed")) if (!currentUser.value) {
toast.error(t("profile.no_permission").toString())
return
}
try {
const res = await axios.post(
"https://api.github.com/gists",
{
files: {
"hoppscotch-collections.json": {
content: collectionJson.value,
},
},
},
{
headers: {
Authorization: `token ${currentUser.value.accessToken}`,
Accept: "application/vnd.github.v3+json",
},
}
)
toast.success(t("export.gist_created").toString())
window.open(res.data.html_url)
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
console.error(e)
}
} }
const handleImportToStore = async ( const fileImported = () => {
gqlCollections: HoppCollection<HoppGQLRequest>[] toast.success(t("state.file_imported").toString())
) => {
setGraphqlCollections(gqlCollections)
toast.success(t("import.success"))
} }
const emit = defineEmits<{ const failedImport = () => {
(e: "hide-modal"): () => void toast.error(t("import.failed").toString())
}>() }
const readCollectionGist = async () => {
const gist = prompt(t("import.gist_url").toString())
if (!gist) return
try {
const { files } = (await axios.get(
`https://api.github.com/gists/${gist.split("/").pop()}`,
{
headers: {
Accept: "application/vnd.github.v3+json",
},
}
)) as {
files: {
[fileName: string]: {
content: any
}
}
}
const collections = JSON.parse(Object.values(files)[0].content)
setGraphqlCollections(collections)
fileImported()
} catch (e) {
failedImport()
console.error(e)
}
}
const hideModal = () => {
emit("hide-modal")
}
const openDialogChooseFileToImportFrom = () => {
if (inputChooseFileToImportFrom.value)
inputChooseFileToImportFrom.value.click()
}
const importFromJSON = () => {
if (!inputChooseFileToImportFrom.value) return
if (
!inputChooseFileToImportFrom.value.files ||
inputChooseFileToImportFrom.value.files.length === 0
) {
toast.show(t("action.choose_file").toString())
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
toast.show(t("action.choose_file").toString())
return
}
const collections = JSON.parse(content)
if (collections[0]) {
const [name, folders, requests] = Object.keys(collections[0])
if (name === "name" && folders === "folders" && requests === "requests") {
// Do nothing
}
} else {
failedImport()
return
}
appendGraphqlCollections(collections)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
importer: "json",
workspaceType: "personal",
platform: "gql",
})
fileImported()
}
reader.readAsText(inputChooseFileToImportFrom.value.files[0])
inputChooseFileToImportFrom.value.value = ""
}
const exportJSON = async () => {
const dataToWrite = collectionJson.value
const parsedCollections = JSON.parse(dataToWrite)
if (!parsedCollections.length) {
return toast.error(t("error.no_collections_to_export"))
}
const file = new Blob([dataToWrite], { type: "application/json" })
const url = URL.createObjectURL(file)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "Hoppscotch Collection JSON file",
extensions: ["json"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
platform?.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "gql",
})
toast.success(t("state.download_started").toString())
}
}
</script> </script>

View File

@@ -11,7 +11,7 @@
type="search" type="search"
autocomplete="off" autocomplete="off"
:placeholder="t('action.search')" :placeholder="t('action.search')"
class="flex w-full bg-transparent px-4 py-2 h-8" class="!border-0 bg-transparent py-2 pl-4 pr-2"
/> />
<div <div
class="flex flex-1 flex-shrink-0 justify-between border-y border-dividerLight bg-primary" class="flex flex-1 flex-shrink-0 justify-between border-y border-dividerLight bg-primary"
@@ -66,36 +66,34 @@
:alt="`${t('empty.collections')}`" :alt="`${t('empty.collections')}`"
:text="t('empty.collections')" :text="t('empty.collections')"
> >
<template #body> <div class="flex flex-col items-center space-y-4">
<div class="flex flex-col items-center space-y-4"> <span class="text-center text-secondaryLight">
<span class="text-center text-secondaryLight"> {{ t("collection.import_or_create") }}
{{ t("collection.import_or_create") }} </span>
</span> <div class="flex flex-col items-stretch gap-4">
<div class="flex flex-col items-stretch gap-4"> <HoppButtonPrimary
<HoppButtonPrimary :icon="IconImport"
:icon="IconImport" :label="t('import.title')"
:label="t('import.title')" filled
filled outline
outline @click="displayModalImportExport(true)"
@click="displayModalImportExport(true)" />
/> <HoppButtonSecondary
<HoppButtonSecondary :label="t('add.new')"
:label="t('add.new')" filled
filled outline
outline :icon="IconPlus"
:icon="IconPlus" @click="displayModalAdd(true)"
@click="displayModalAdd(true)" />
/>
</div>
</div> </div>
</template> </div>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-if="!(filteredCollections.length !== 0 || collections.length === 0)" v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
:text="`${t('state.nothing_found')}${filterText}`" :text="`${t('state.nothing_found')}${filterText}`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="svg-icons opacity-75" /> <icon-lucide-search class="svg-icons pb-2 opacity-75" />
</template> </template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<CollectionsGraphqlAdd <CollectionsGraphqlAdd
@@ -139,7 +137,7 @@
@hide-modal="displayModalEditRequest(false)" @hide-modal="displayModalEditRequest(false)"
/> />
<CollectionsGraphqlImportExport <CollectionsGraphqlImportExport
v-if="showModalImportExport" :show="showModalImportExport"
@hide-modal="displayModalImportExport(false)" @hide-modal="displayModalImportExport(false)"
/> />
</div> </div>

View File

@@ -11,7 +11,7 @@
@dragend="draggingToRoot = false" @dragend="draggingToRoot = false"
> >
<div <div
class="sticky z-10 flex flex-shrink-0 flex-col overflow-x-auto bg-primary border-b border-dividerLight" class="sticky z-10 flex flex-shrink-0 flex-col overflow-x-auto border-b border-dividerLight bg-primary"
:class="{ 'rounded-t': saveRequest }" :class="{ 'rounded-t': saveRequest }"
:style=" :style="
saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0' saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0'
@@ -22,7 +22,7 @@
v-model="filterTexts" v-model="filterTexts"
type="search" type="search"
autocomplete="off" autocomplete="off"
class="flex w-full bg-transparent px-4 py-2 h-8" class="flex h-8 w-full bg-transparent p-4 py-2"
:placeholder="t('action.search')" :placeholder="t('action.search')"
:disabled="collectionsType.type === 'team-collections'" :disabled="collectionsType.type === 'team-collections'"
/> />
@@ -41,7 +41,6 @@
@export-data="exportData" @export-data="exportData"
@remove-collection="removeCollection" @remove-collection="removeCollection"
@remove-folder="removeFolder" @remove-folder="removeFolder"
@share-request="shareRequest"
@drop-collection="dropCollection" @drop-collection="dropCollection"
@update-request-order="updateRequestOrder" @update-request-order="updateRequestOrder"
@update-collection-order="updateCollectionOrder" @update-collection-order="updateCollectionOrder"
@@ -72,7 +71,6 @@
@export-data="exportData" @export-data="exportData"
@remove-collection="removeCollection" @remove-collection="removeCollection"
@remove-folder="removeFolder" @remove-folder="removeFolder"
@share-request="shareRequest"
@edit-request="editRequest" @edit-request="editRequest"
@duplicate-request="duplicateRequest" @duplicate-request="duplicateRequest"
@remove-request="removeRequest" @remove-request="removeRequest"
@@ -140,13 +138,17 @@
@hide-modal="showConfirmModal = false" @hide-modal="showConfirmModal = false"
@resolve="resolveConfirmModal" @resolve="resolveConfirmModal"
/> />
<CollectionsImportExport <CollectionsImportExport
v-if="showModalImportExport" :show="showModalImportExport"
:collections-type="collectionsType" :collections-type="collectionsType.type"
:exporting-team-collections="exportingTeamCollections"
:creating-gist-collection="creatingGistCollection"
:importing-my-collections="importingMyCollections"
@export-json-collection="exportJSONCollection"
@create-collection-gist="createCollectionGist"
@import-to-teams="importToTeams"
@hide-modal="displayModalImportExport(false)" @hide-modal="displayModalImportExport(false)"
/> />
<TeamsAdd <TeamsAdd
:show="showTeamModalAdd" :show="showTeamModalAdd"
@hide-modal="displayTeamModalAdd(false)" @hide-modal="displayTeamModalAdd(false)"
@@ -195,6 +197,7 @@ import {
createChildCollection, createChildCollection,
renameCollection, renameCollection,
deleteCollection, deleteCollection,
importJSONToTeam,
moveRESTTeamCollection, moveRESTTeamCollection,
updateOrderRESTTeamCollection, updateOrderRESTTeamCollection,
} from "~/helpers/backend/mutations/TeamCollection" } from "~/helpers/backend/mutations/TeamCollection"
@@ -209,9 +212,12 @@ import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { Collection as NodeCollection } from "./MyCollections.vue" import { Collection as NodeCollection } from "./MyCollections.vue"
import { import {
getCompleteCollectionTree, getCompleteCollectionTree,
getTeamCollectionJSON,
teamCollToHoppRESTColl, teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers" } from "~/helpers/backend/helpers"
import * as E from "fp-ts/Either"
import { platform } from "~/platform" import { platform } from "~/platform"
import { createCollectionGists } from "~/helpers/gist"
import { import {
getRequestsByPath, getRequestsByPath,
resolveSaveContextOnRequestReorder, resolveSaveContextOnRequestReorder,
@@ -223,7 +229,7 @@ import {
resetTeamRequestsContext, resetTeamRequestsContext,
} from "~/helpers/collection/collection" } from "~/helpers/collection/collection"
import { currentReorderingStatus$ } from "~/newstore/reordering" import { currentReorderingStatus$ } from "~/newstore/reordering"
import { defineActionHandler, invokeAction } from "~/helpers/actions" import { defineActionHandler } from "~/helpers/actions"
import { WorkspaceService } from "~/services/workspace.service" import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
@@ -297,6 +303,12 @@ const draggingToRoot = ref(false)
const collectionMoveLoading = ref<string[]>([]) const collectionMoveLoading = ref<string[]>([])
const requestMoveLoading = ref<string[]>([]) const requestMoveLoading = ref<string[]>([])
// Export - Import refs
const collectionJSON = ref("")
const exportingTeamCollections = ref(false)
const creatingGistCollection = ref(false)
const importingMyCollections = ref(false)
// TeamList-Adapter // TeamList-Adapter
const workspaceService = useService(WorkspaceService) const workspaceService = useService(WorkspaceService)
const teamListAdapter = workspaceService.acquireTeamListAdapter(null) const teamListAdapter = workspaceService.acquireTeamListAdapter(null)
@@ -400,12 +412,14 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
}) })
const hasTeamWriteAccess = computed(() => { const hasTeamWriteAccess = computed(() => {
if (collectionsType.value.type !== "team-collections") { if (!collectionsType.value.selectedTeam) return false
return false
}
const role = collectionsType.value.selectedTeam?.myRole if (
return role === "OWNER" || role === "EDITOR" collectionsType.value.type === "team-collections" &&
collectionsType.value.selectedTeam.myRole !== "VIEWER"
)
return true
else return false
}) })
const filteredCollections = computed(() => { const filteredCollections = computed(() => {
@@ -1055,7 +1069,7 @@ const onRemoveCollection = () => {
const collectionIndex = editingCollectionIndex.value const collectionIndex = editingCollectionIndex.value
const collectionToRemove = const collectionToRemove =
collectionIndex || collectionIndex === 0 collectionIndex || collectionIndex == 0
? navigateToFolderWithIndexPath(restCollectionStore.value.state, [ ? navigateToFolderWithIndexPath(restCollectionStore.value.state, [
collectionIndex, collectionIndex,
]) ])
@@ -1454,8 +1468,9 @@ const checkIfCollectionIsAParentOfTheChildren = (
) )
if (isEqual(slicedDestinationCollectionPath, collectionDraggedPath)) { if (isEqual(slicedDestinationCollectionPath, collectionDraggedPath)) {
return true return true
} else {
return false
} }
return false
} }
return false return false
@@ -1476,8 +1491,9 @@ const isMoveToSameLocation = (
if (isEqual(draggedItemParentPathArr, destinationPathArr)) { if (isEqual(draggedItemParentPathArr, destinationPathArr)) {
return true return true
} else {
return false
} }
return false
} }
} }
@@ -1657,22 +1673,25 @@ const isSameSameParent = (
const dragedItemParent = draggedItemIndex.slice(0, -1) const dragedItemParent = draggedItemIndex.slice(0, -1)
return dragedItemParent.join("/") === destinationCollectionIndex return dragedItemParent.join("/") === destinationCollectionIndex
} } else {
if (destinationItemPath === null) return false if (destinationItemPath === null) return false
const destinationItemIndex = pathToIndex(destinationItemPath) const destinationItemIndex = pathToIndex(destinationItemPath)
// length of 1 means the request is in the root // length of 1 means the request is in the root
if (draggedItemIndex.length === 1 && destinationItemIndex.length === 1) { if (draggedItemIndex.length === 1 && destinationItemIndex.length === 1) {
return true
} else if (draggedItemIndex.length === destinationItemIndex.length) {
const dragedItemParent = draggedItemIndex.slice(0, -1)
const destinationItemParent = destinationItemIndex.slice(0, -1)
if (isEqual(dragedItemParent, destinationItemParent)) {
return true return true
} else if (draggedItemIndex.length === destinationItemIndex.length) {
const dragedItemParent = draggedItemIndex.slice(0, -1)
const destinationItemParent = destinationItemIndex.slice(0, -1)
if (isEqual(dragedItemParent, destinationItemParent)) {
return true
} else {
return false
}
} else {
return false
} }
return false
} }
return false
} }
/** /**
@@ -1814,6 +1833,33 @@ const updateCollectionOrder = (payload: {
} }
} }
// Import - Export Collection functions // Import - Export Collection functions
/**
* Export the whole my collection or specific team collection to JSON
*/
const getJSONCollection = async () => {
if (collectionsType.value.type === "my-collections") {
collectionJSON.value = JSON.stringify(myCollections.value, null, 2)
} else {
if (!collectionsType.value.selectedTeam) return
exportingTeamCollections.value = true
pipe(
await getTeamCollectionJSON(collectionsType.value.selectedTeam.id),
E.match(
(err) => {
toast.error(`${getErrorMessage(err)}`)
exportingTeamCollections.value = false
},
(result) => {
const { exportCollectionsToJSON } = result
collectionJSON.value = exportCollectionsToJSON
exportingTeamCollections.value = false
}
)
)
}
return collectionJSON.value
}
/** /**
* Create a downloadable file from a collection and prompts the user to download it. * Create a downloadable file from a collection and prompts the user to download it.
@@ -1882,15 +1928,88 @@ const exportData = async (
} }
} }
const shareRequest = ({ request }: { request: HoppRESTRequest }) => { const exportJSONCollection = async () => {
if (currentUser.value) { platform.analytics?.logEvent({
// opens the share request modal type: "HOPP_EXPORT_COLLECTION",
invokeAction("share.request", { exporter: "json",
request, platform: "rest",
}) })
} else {
invokeAction("modals.login.toggle") await getJSONCollection()
const parsedCollections = JSON.parse(collectionJSON.value)
if (!parsedCollections.length) {
return toast.error(t("error.no_collections_to_export"))
} }
initializeDownloadCollection(collectionJSON.value, null)
}
const createCollectionGist = async () => {
if (!currentUser.value || !currentUser.value.accessToken) {
toast.error(t("profile.no_permission").toString())
return
}
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "gist",
platform: "rest",
})
creatingGistCollection.value = true
await getJSONCollection()
pipe(
createCollectionGists(collectionJSON.value, currentUser.value.accessToken),
TE.match(
(err) => {
toast.error(t("error.something_went_wrong").toString())
console.error(err)
creatingGistCollection.value = false
},
(result) => {
toast.success(t("export.gist_created").toString())
creatingGistCollection.value = false
window.open(result.data.html_url)
}
)
)()
}
const importToTeams = async (collection: HoppCollection<HoppRESTRequest>[]) => {
if (!hasTeamWriteAccess.value) {
toast.error(t("team.no_access").toString())
return
}
if (!collectionsType.value.selectedTeam) return
importingMyCollections.value = true
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "import-to-teams",
platform: "rest",
})
pipe(
importJSONToTeam(
JSON.stringify(collection),
collectionsType.value.selectedTeam.id
),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
importingMyCollections.value = false
},
() => {
importingMyCollections.value = false
displayModalImportExport(false)
}
)
)()
} }
const resolveConfirmModal = (title: string | null) => { const resolveConfirmModal = (title: string | null) => {
@@ -1922,36 +2041,37 @@ const getErrorMessage = (err: GQLError<string>) => {
console.error(err) console.error(err)
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} } else {
switch (err.error) { switch (err.error) {
case "team_coll/short_title": case "team_coll/short_title":
return t("collection.name_length_insufficient") return t("collection.name_length_insufficient")
case "team/invalid_coll_id": case "team/invalid_coll_id":
case "bug/team_coll/no_coll_id": case "bug/team_coll/no_coll_id":
case "team_req/invalid_target_id": case "team_req/invalid_target_id":
return t("team.invalid_coll_id") return t("team.invalid_coll_id")
case "team/not_required_role": case "team/not_required_role":
return t("profile.no_permission") return t("profile.no_permission")
case "team_req/not_required_role": case "team_req/not_required_role":
return t("profile.no_permission") return t("profile.no_permission")
case "Forbidden resource": case "Forbidden resource":
return t("profile.no_permission") return t("profile.no_permission")
case "team_req/not_found": case "team_req/not_found":
return t("team.no_request_found") return t("team.no_request_found")
case "bug/team_req/no_req_id": case "bug/team_req/no_req_id":
return t("team.no_request_found") return t("team.no_request_found")
case "team/collection_is_parent_coll": case "team/collection_is_parent_coll":
return t("team.parent_coll_move") return t("team.parent_coll_move")
case "team/target_and_destination_collection_are_same": case "team/target_and_destination_collection_are_same":
return t("team.same_target_destination") return t("team.same_target_destination")
case "team/target_collection_is_already_root_collection": case "team/target_collection_is_already_root_collection":
return t("collection.invalid_root_move") return t("collection.invalid_root_move")
case "team_req/requests_not_from_same_collection": case "team_req/requests_not_from_same_collection":
return t("request.different_collection") return t("request.different_collection")
case "team/team_collections_have_different_parents": case "team/team_collections_have_different_parents":
return t("collection.different_parent") return t("collection.different_parent")
default: default:
return t("error.something_went_wrong") return t("error.something_went_wrong")
}
} }
} }

View File

@@ -11,9 +11,7 @@
v-if="!currentInterceptorSupportsCookies" v-if="!currentInterceptorSupportsCookies"
:text="t('cookies.modal.interceptor_no_support')" :text="t('cookies.modal.interceptor_no_support')"
> >
<template #body> <AppInterceptor class="rounded border border-dividerLight p-2" />
<AppInterceptor class="rounded border border-dividerLight p-2" />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<div v-else class="flex flex-col"> <div v-else class="flex flex-col">
<div <div
@@ -40,7 +38,8 @@
:alt="`${t('cookies.modal.empty_domains')}`" :alt="`${t('cookies.modal.empty_domains')}`"
:text="t('cookies.modal.empty_domains')" :text="t('cookies.modal.empty_domains')"
class="mt-6" class="mt-6"
/> >
</HoppSmartPlaceholder>
<div <div
v-for="[domain, entries] in workingCookieJar.entries()" v-for="[domain, entries] in workingCookieJar.entries()"
v-else v-else

View File

@@ -1,208 +0,0 @@
<template>
<div class="flex flex-col flex-1">
<header
class="flex items-center justify-between flex-1 flex-shrink-0 px-2 py-2 space-x-2 overflow-x-auto overflow-y-hidden"
>
<div class="flex items-center justify-between flex-1 space-x-2">
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="https://hoppscotch.io"
blank
/>
<div class="flex">
<HoppButtonSecondary
:label="t('app.open_in_hoppscotch')"
:to="sharedRequestURL"
blank
/>
</div>
</div>
</header>
<div class="sticky top-0 z-10 flex-1">
<div
class="flex-none flex-shrink-0 p-4 bg-primary sm:flex sm:flex-shrink-0 sm:space-x-2"
>
<div
class="flex flex-1 overflow-hidden border divide-x rounded text-secondaryDark divide-divider min-w-[12rem] overflow-x-auto border-divider"
>
<span
class="flex items-center justify-center px-4 py-2 font-semibold transition rounded-l"
>
{{ tab.document.request.method }}
</span>
<div
class="flex items-center flex-1 flex-shrink-0 min-w-0 px-4 py-2 truncate rounded-r"
>
{{ tab.document.request.endpoint }}
</div>
</div>
<div class="flex mt-2 space-x-2 sm:mt-0">
<HoppButtonPrimary
id="send"
:title="`${t(
'action.send'
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
:label="`${!loading ? t('action.send') : t('action.cancel')}`"
class="flex-1 min-w-20"
outline
@click="!loading ? newSendRequest() : cancelRequest()"
/>
<div class="flex">
<HoppButtonSecondary
:title="`${t(
'request.save'
)} <kbd>${getSpecialKey()}</kbd><kbd>S</kbd>`"
:label="t('request.save')"
filled
:icon="IconSave"
class="flex-1 rounded"
blank
outline
:to="sharedRequestURL"
/>
</div>
</div>
</div>
</div>
<HttpRequestOptions
v-model="tab.document.request"
v-model:option-tab="selectedOptionTab"
:properties="properties"
/>
<HttpResponse :document="tab.document" :is-embed="true" />
</div>
</template>
<script lang="ts" setup>
import { Ref } from "vue"
import { computed, useModel } from "vue"
import { ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import * as E from "fp-ts/Either"
import { useStreamSubscriber } from "~/composables/stream"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { runRESTRequest$ } from "~/helpers/RequestRunner"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
import IconSave from "~icons/lucide/save"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
modelTab: HoppTab<HoppRESTDocument>
properties: string[]
sharedRequestID: string
}>()
const tab = useModel(props, "modelTab")
const selectedOptionTab = ref(props.properties[0])
const requestCancelFunc: Ref<(() => void) | null> = ref(null)
const loading = ref(false)
const shortcodeBaseURL =
import.meta.env.VITE_SHORTCODE_BASE_URL ?? "https://hopp.sh"
const sharedRequestURL = computed(() => {
return `${shortcodeBaseURL}/r/${props.sharedRequestID}`
})
const { subscribeToStream } = useStreamSubscriber()
const newSendRequest = async () => {
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
toast.error(`${t("empty.endpoint")}`)
return
}
ensureMethodInEndpoint()
loading.value = true
const [cancel, streamPromise] = runRESTRequest$(tab)
const streamResult = await streamPromise
requestCancelFunc.value = cancel
if (E.isRight(streamResult)) {
subscribeToStream(
streamResult.right,
(responseState) => {
if (loading.value) {
// Check exists because, loading can be set to false
// when cancelled
updateRESTResponse(responseState)
}
},
() => {
loading.value = false
},
() => {
// TODO: Change this any to a proper type
const result = (streamResult.right as any).value
if (
result.type === "network_fail" &&
result.error?.error === "NO_PW_EXT_HOOK"
) {
const errorResponse: HoppRESTResponse = {
type: "extension_error",
error: result.error.humanMessage.heading,
component: result.error.component,
req: result.req,
}
updateRESTResponse(errorResponse)
}
loading.value = false
}
)
} else {
loading.value = false
toast.error(`${t("error.script_fail")}`)
let error: Error
if (typeof streamResult.left === "string") {
error = { name: "RequestFailure", message: streamResult.left }
} else {
error = streamResult.left
}
updateRESTResponse({
type: "script_fail",
error,
})
}
}
const updateRESTResponse = (response: HoppRESTResponse | null) => {
tab.value.document.response = response
}
const newEndpoint = computed(() => {
return tab.value.document.request.endpoint
})
const ensureMethodInEndpoint = () => {
if (
!/^http[s]?:\/\//.test(newEndpoint.value) &&
!newEndpoint.value.startsWith("<<")
) {
const domain = newEndpoint.value.split(/[/:#?]+/)[0]
if (domain === "localhost" || /([0-9]+\.)*[0-9]/.test(domain)) {
tab.value.document.request.endpoint =
"http://" + tab.value.document.request.endpoint
} else {
tab.value.document.request.endpoint =
"https://" + tab.value.document.request.endpoint
}
}
}
const cancelRequest = () => {
loading.value = false
requestCancelFunc.value?.()
updateRESTResponse(null)
}
</script>

View File

@@ -7,7 +7,7 @@
<template #body> <template #body>
<div class="flex flex-1 flex-col space-y-4"> <div class="flex flex-1 flex-col space-y-4">
<div class="ml-2 flex items-center space-x-8"> <div class="ml-2 flex items-center space-x-8">
<label for="name" class="min-w-[2.5rem] font-semibold">{{ <label for="name" class="min-w-10 font-semibold">{{
t("environment.name") t("environment.name")
}}</label> }}</label>
<input <input
@@ -18,7 +18,7 @@
/> />
</div> </div>
<div class="ml-2 flex items-center space-x-8"> <div class="ml-2 flex items-center space-x-8">
<label for="value" class="min-w-[2.5rem] font-semibold">{{ <label for="value" class="min-w-10 font-semibold">{{
t("environment.value") t("environment.value")
}}</label> }}</label>
<input <input
@@ -29,7 +29,7 @@
/> />
</div> </div>
<div class="ml-2 flex items-center space-x-8"> <div class="ml-2 flex items-center space-x-8">
<label for="scope" class="min-w-[2.5rem] font-semibold"> <label for="scope" class="min-w-10 font-semibold">
{{ t("environment.scope") }} {{ t("environment.scope") }}
</label> </label>
<div <div
@@ -39,10 +39,10 @@
</div> </div>
</div> </div>
<div v-if="replaceWithVariable" class="mt-3 flex space-x-2"> <div v-if="replaceWithVariable" class="mt-3 flex space-x-2">
<div class="min-w-[4rem]" /> <div class="min-w-18" />
<HoppSmartCheckbox <HoppSmartCheckbox
:on="replaceWithVariable" :on="replaceWithVariable"
:title="t('environment.replace_with_variable')" title="t('environment.replace_with_variable'))"
@change="replaceWithVariable = !replaceWithVariable" @change="replaceWithVariable = !replaceWithVariable"
/> />
<label for="replaceWithVariable"> <label for="replaceWithVariable">
@@ -205,14 +205,15 @@ const addEnvironment = async () => {
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} } else {
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
case "Forbidden resource": case "Forbidden resource":
return t("profile.no_permission") return t("profile.no_permission")
default: default:
return t("error.something_went_wrong") return t("error.something_went_wrong")
}
} }
} }
</script> </script>

View File

@@ -1,60 +1,154 @@
<template> <template>
<ImportExportBase <HoppSmartModal
ref="collections-import-export" v-if="show"
modal-title="environment.title" dialog
:importer-modules="importerModules" :title="`${t('environment.title')}`"
:exporter-modules="exporterModules" styles="sm:max-w-md"
@hide-modal="emit('hide-modal')" @close="hideModal"
/> >
<template #actions>
<span>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconGithub"
:label="t('import.from_gist')"
@click="
() => {
readEnvironmentGist()
hide()
}
"
/>
<span
v-tippy="{ theme: 'tooltip' }"
:title="
!currentUser
? `${t('export.require_github')}`
: currentUser.provider !== 'github.com'
? `${t('export.require_github')}`
: undefined
"
>
<HoppSmartItem
:disabled="
!currentUser
? true
: currentUser.provider !== 'github.com'
? true
: false
"
:icon="IconGithub"
:label="t('export.create_secret_gist')"
@click="
() => {
createEnvironmentGist()
hide()
}
"
/>
</span>
</div>
</template>
</tippy>
</span>
</template>
<template #body>
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
<HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div v-else class="flex flex-col space-y-2">
<HoppSmartItem
:icon="IconFolderPlus"
:label="t('import.from_json')"
@click="openDialogChooseFileToImportFrom"
/>
<input
ref="inputChooseFileToImportFrom"
class="input"
type="file"
accept="application/json"
@change="importFromJSON"
/>
<hr />
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:label="t('export.as_json')"
@click="exportJSON"
/>
</div>
</template>
</HoppSmartModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "~/composables/i18n" import IconMoreVertical from "~icons/lucide/more-vertical"
import { useToast } from "~/composables/toast"
import { Environment } from "@hoppscotch/data"
import { ImporterOrExporter } from "~/components/importExport/types"
import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import { hoppEnvImporter } from "~/helpers/import-export/import/hoppEnv"
import * as E from "fp-ts/Either"
import { appendEnvironments, environments$ } from "~/newstore/environments"
import { createTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { GQLError } from "~/helpers/backend/GQLClient"
import { CreateTeamEnvironmentMutation } from "~/helpers/backend/graphql"
import { postmanEnvImporter } from "~/helpers/import-export/import/postmanEnv"
import IconFolderPlus from "~icons/lucide/folder-plus" import IconFolderPlus from "~icons/lucide/folder-plus"
import IconPostman from "~icons/hopp/postman" import IconDownload from "~icons/lucide/download"
import IconUser from "~icons/lucide/user" import IconGithub from "~icons/lucide/github"
import { initializeDownloadCollection } from "~/helpers/import-export/export" import { computed, ref } from "vue"
import { computed } from "vue" import { Environment } from "@hoppscotch/data"
import { useReadonlyStream } from "~/composables/stream"
import { environmentsExporter } from "~/helpers/import-export/export/environments"
import { environmentsGistExporter } from "~/helpers/import-export/export/environmentsGistExport"
import { platform } from "~/platform" import { platform } from "~/platform"
import axios from "axios"
const t = useI18n() import { useI18n } from "@composables/i18n"
const toast = useToast() import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import {
environments$,
replaceEnvironments,
appendEnvironments,
} from "~/newstore/environments"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { createTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { GQLError } from "~/helpers/backend/GQLClient"
import { TippyComponent } from "vue-tippy"
const props = defineProps<{ const props = defineProps<{
show: boolean
teamEnvironments?: TeamEnvironment[] teamEnvironments?: TeamEnvironment[]
teamId?: string | undefined teamId?: string | undefined
environmentType: "MY_ENV" | "TEAM_ENV" environmentType: "MY_ENV" | "TEAM_ENV"
}>() }>()
const myEnvironments = useReadonlyStream(environments$, []) const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const toast = useToast()
const t = useI18n()
const loading = ref(false)
const myEnvironments = useReadonlyStream(environments$, [])
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(), platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser() platform.auth.getCurrentUser()
) )
const isTeamEnvironment = computed(() => { // Template refs
return props.environmentType === "TEAM_ENV" const tippyActions = ref<TippyComponent | null>(null)
}) const inputChooseFileToImportFrom = ref<HTMLInputElement>()
const environmentJson = computed(() => { const environmentJson = computed(() => {
if ( if (
@@ -64,249 +158,266 @@ const environmentJson = computed(() => {
const teamEnvironments = props.teamEnvironments.map( const teamEnvironments = props.teamEnvironments.map(
(x) => x.environment as Environment (x) => x.environment as Environment
) )
return teamEnvironments return JSON.stringify(teamEnvironments, null, 2)
} else {
return JSON.stringify(myEnvironments.value, null, 2)
} }
return myEnvironments.value
}) })
const HoppEnvironmentsImport: ImporterOrExporter = { const createEnvironmentGist = async () => {
metadata: { if (!currentUser.value) {
id: "import.from_json", toast.error(t("profile.no_permission").toString())
name: "import.from_json",
icon: IconFolderPlus,
title: "import.from_json",
applicableTo: ["personal-workspace", "team-workspace"],
disabled: false,
},
component: FileSource({
acceptedFileTypes: "application/json",
caption: "import.hoppscotch_environment_description",
onImportFromFile: async (environments) => {
const res = await hoppEnvImporter(environments)()
if (E.isLeft(res)) { return
showImportFailedError() }
return
try {
const res = await axios.post(
"https://api.github.com/gists",
{
files: {
"hoppscotch-environments.json": {
content: environmentJson.value,
},
},
},
{
headers: {
Authorization: `token ${currentUser.value.accessToken}`,
Accept: "application/vnd.github.v3+json",
},
} }
handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: isTeamEnvironment.value ? "team" : "personal",
})
emit("hide-modal")
},
}),
}
const PostmanEnvironmentsImport: ImporterOrExporter = {
metadata: {
id: "import.from_postman",
name: "import.from_postman",
icon: IconPostman,
title: "import.from_json",
applicableTo: ["personal-workspace", "team-workspace"],
disabled: false,
},
component: FileSource({
acceptedFileTypes: "application/json",
caption: "import.postman_environment_description",
onImportFromFile: async (environments) => {
const res = await postmanEnvImporter(environments)()
if (E.isLeft(res)) {
showImportFailedError()
return
}
handleImportToStore([res.right])
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: isTeamEnvironment.value ? "team" : "personal",
})
emit("hide-modal")
},
}),
}
const EnvironmentsImportFromGIST: ImporterOrExporter = {
metadata: {
id: "import.environments_from_gist",
name: "import.environments_from_gist",
icon: IconFolderPlus,
title: "import.environments_from_gist",
applicableTo: ["personal-workspace", "team-workspace"],
disabled: false,
},
component: GistSource({
caption: "import.environments_from_gist_description",
onImportFromGist: async (environments) => {
if (E.isLeft(environments)) {
showImportFailedError()
return
}
const res = await hoppEnvImporter(environments.right)()
if (E.isLeft(res)) {
showImportFailedError()
return
}
handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: isTeamEnvironment.value ? "team" : "personal",
})
emit("hide-modal")
},
}),
}
const HoppEnvironmentsExport: ImporterOrExporter = {
metadata: {
id: "export.as_json",
name: "export.as_json",
title: "action.download_file",
icon: IconUser,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace"],
},
action: () => {
if (!environmentJson.value.length) {
return toast.error(t("error.no_environments_to_export"))
}
const message = initializeDownloadCollection(
environmentsExporter(environmentJson.value),
"Environments"
) )
if (E.isLeft(message)) { toast.success(t("export.gist_created").toString())
toast.error(t(message.left))
return
}
toast.success(t(message.right))
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_EXPORT_ENVIRONMENT", type: "HOPP_EXPORT_ENVIRONMENT",
platform: "rest", platform: "rest",
}) })
},
}
const HoppEnvironmentsGistExporter: ImporterOrExporter = { window.open(res.data.html_url)
metadata: { } catch (e) {
id: "export.as_gist", toast.error(t("error.something_went_wrong").toString())
name: "export.create_secret_gist", console.error(e)
title:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
currentUser?.provider === "github.com"
? "export.create_secret_gist"
: "export.require_github",
icon: IconUser,
disabled: !currentUser.value
? true
: currentUser.value.provider !== "github.com",
applicableTo: ["personal-workspace", "team-workspace"],
},
action: async () => {
if (!currentUser.value) {
toast.error(t("profile.no_permission"))
return
}
const accessToken = currentUser.value?.accessToken
if (accessToken) {
const res = await environmentsGistExporter(
JSON.stringify(environmentJson.value),
accessToken
)
if (E.isLeft(res)) {
toast.error(t("export.failed"))
return
}
toast.success(t("export.success"))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_ENVIRONMENT",
platform: "rest",
})
window.open(res.right, "_blank")
}
},
}
const importerModules = [
HoppEnvironmentsImport,
EnvironmentsImportFromGIST,
PostmanEnvironmentsImport,
]
const exporterModules = computed(() => {
const enabledExporters = [HoppEnvironmentsExport]
if (platform.platformFeatureFlags.exportAsGIST) {
enabledExporters.push(HoppEnvironmentsGistExporter)
} }
}
return enabledExporters const fileImported = () => {
}) toast.success(t("state.file_imported").toString())
}
const showImportFailedError = () => { const failedImport = () => {
toast.error(t("import.failed").toString()) toast.error(t("import.failed").toString())
} }
const handleImportToStore = async (environments: Environment[]) => { const readEnvironmentGist = async () => {
if (props.environmentType === "MY_ENV") { const gist = prompt(t("import.gist_url").toString())
appendEnvironments(environments) if (!gist) return
toast.success(t("state.file_imported"))
} else { try {
await importToTeams(environments) const { files } = (await axios.get(
`https://api.github.com/gists/${gist.split("/").pop()}`,
{
headers: {
Accept: "application/vnd.github.v3+json",
},
}
)) as {
files: {
[fileName: string]: {
content: any
}
}
}
const environments = JSON.parse(Object.values(files)[0].content)
if (props.environmentType === "MY_ENV") {
replaceEnvironments(environments)
fileImported()
} else {
importToTeams(environments)
}
} catch (e) {
failedImport()
console.error(e)
} }
} }
const hideModal = () => {
emit("hide-modal")
}
const openDialogChooseFileToImportFrom = () => {
if (inputChooseFileToImportFrom.value)
inputChooseFileToImportFrom.value.click()
}
const importToTeams = async (content: Environment[]) => { const importToTeams = async (content: Environment[]) => {
const envImportPromises: Promise< loading.value = true
E.Either<GQLError<"">, CreateTeamEnvironmentMutation>
>[] = []
for (const [, env] of content.entries()) { platform.analytics?.logEvent({
const res = createTeamEnvironment( type: "HOPP_IMPORT_ENVIRONMENT",
JSON.stringify(env.variables), platform: "rest",
props.teamId as string, workspaceType: "team",
env.name })
)()
envImportPromises.push(res) for (const [i, env] of content.entries()) {
} if (i === content.length - 1) {
await pipe(
const res = await Promise.all(envImportPromises) createTeamEnvironment(
JSON.stringify(env.variables),
const failedImports = res.some((r) => E.isLeft(r)) props.teamId as string,
env.name
if (failedImports) { ),
toast.error(t("import.failed")) TE.match(
} else { (err: GQLError<string>) => {
toast.success(t("import.success")) console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
loading.value = false
hideModal()
fileImported()
}
)
)()
} else {
await pipe(
createTeamEnvironment(
JSON.stringify(env.variables),
props.teamId as string,
env.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
// wait for all the environments to be created then fire the toast
}
)
)()
}
} }
} }
const emit = defineEmits<{ const importFromJSON = () => {
(e: "hide-modal"): () => void if (!inputChooseFileToImportFrom.value) return
}>()
if (
!inputChooseFileToImportFrom.value.files ||
inputChooseFileToImportFrom.value.files.length === 0
) {
toast.show(t("action.choose_file").toString())
return
}
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: "personal",
})
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
toast.show(t("action.choose_file").toString())
return
}
const environments = JSON.parse(content)
if (
environments._postman_variable_scope === "environment" ||
environments._postman_variable_scope === "globals"
) {
importFromPostman(environments)
} else if (environments[0]) {
const [name, variables] = Object.keys(environments[0])
if (name === "name" && variables === "variables") {
// Do nothing
}
importFromHoppscotch(environments)
} else {
failedImport()
}
}
reader.readAsText(inputChooseFileToImportFrom.value.files[0])
inputChooseFileToImportFrom.value.value = ""
}
const importFromHoppscotch = (environments: Environment[]) => {
if (props.environmentType === "MY_ENV") {
appendEnvironments(environments)
fileImported()
} else {
importToTeams(environments)
}
}
const importFromPostman = ({
name,
values,
}: {
name: string
values: { key: string; value: string }[]
}) => {
const environment: Environment = { name, variables: [] }
values.forEach(({ key, value }) => environment.variables.push({ key, value }))
const environments = [environment]
importFromHoppscotch(environments)
}
const exportJSON = async () => {
const dataToWrite = environmentJson.value
const parsedCollections = JSON.parse(dataToWrite)
if (!parsedCollections.length) {
return toast.error(t("error.no_environments_to_export"))
}
const file = new Blob([dataToWrite], { type: "application/json" })
const url = URL.createObjectURL(file)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
toast.success(t("state.download_started").toString())
}
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "team_environment/not_found":
return t("team_environment.not_found")
default:
return t("error.something_went_wrong")
}
}
}
</script> </script>

View File

@@ -6,9 +6,10 @@
theme="popover" theme="popover"
:on-shown="() => envSelectorActions!.focus()" :on-shown="() => envSelectorActions!.focus()"
> >
<HoppSmartSelectWrapper <span
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="`${t('environment.select')}`" :title="`${t('environment.select')}`"
class="select-wrapper"
> >
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconLayers" :icon="IconLayers"
@@ -21,7 +22,7 @@
" "
class="flex-1 !justify-start rounded-none pr-8" class="flex-1 !justify-start rounded-none pr-8"
/> />
</HoppSmartSelectWrapper> </span>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="envSelectorActions" ref="envSelectorActions"
@@ -93,12 +94,20 @@
} }
" "
/> />
<HoppSmartPlaceholder <div
v-if="myEnvironments.length === 0" v-if="myEnvironments.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`" class="flex flex-col items-center justify-center text-secondaryLight"
:alt="`${t('empty.environments')}`" >
:text="t('empty.environments')" <img
/> :src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="mb-2 inline-flex h-16 w-16 flex-col object-contain object-center"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-2 text-center">
{{ t("empty.environments") }}
</span>
</div>
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
:id="'team-environments'" :id="'team-environments'"
@@ -132,12 +141,20 @@
} }
" "
/> />
<HoppSmartPlaceholder <div
v-if="teamEnvironmentList.length === 0" v-if="teamEnvironmentList.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`" class="flex flex-col items-center justify-center text-secondaryLight"
:alt="`${t('empty.environments')}`" >
:text="t('empty.environments')" <img
/> :src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="mb-2 inline-flex h-16 w-16 flex-col object-contain object-center"
:alt="`${t('empty.environments')}`"
/>
<span class="pb-2 text-center">
{{ t("empty.environments") }}
</span>
</div>
</div> </div>
<div <div
v-if="!teamListLoading && teamAdapterError" v-if="!teamListLoading && teamAdapterError"
@@ -190,14 +207,10 @@
</div> </div>
<div class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2"> <div class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2">
<div class="flex flex-1 space-x-4"> <div class="flex flex-1 space-x-4">
<span <span class="min-w-32 w-1/4 truncate text-tiny font-semibold">
class="min-w-[9rem] w-1/4 truncate text-tiny font-semibold"
>
{{ t("environment.name") }} {{ t("environment.name") }}
</span> </span>
<span <span class="min-w-32 w-full truncate text-tiny font-semibold">
class="min-w-[9rem] w-full truncate text-tiny font-semibold"
>
{{ t("environment.value") }} {{ t("environment.value") }}
</span> </span>
</div> </div>
@@ -206,10 +219,10 @@
:key="index" :key="index"
class="flex flex-1 space-x-4" class="flex flex-1 space-x-4"
> >
<span class="min-w-[9rem] w-1/4 truncate text-secondaryLight"> <span class="min-w-32 w-1/4 truncate text-secondaryLight">
{{ variable.key }} {{ variable.key }}
</span> </span>
<span class="min-w-[9rem] w-full truncate text-secondaryLight"> <span class="min-w-32 w-full truncate text-secondaryLight">
{{ variable.value }} {{ variable.value }}
</span> </span>
</div> </div>
@@ -245,14 +258,10 @@
</div> </div>
<div v-else class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2"> <div v-else class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2">
<div class="flex flex-1 space-x-4"> <div class="flex flex-1 space-x-4">
<span <span class="min-w-32 w-1/4 truncate text-tiny font-semibold">
class="min-w-[9rem] w-1/4 truncate text-tiny font-semibold"
>
{{ t("environment.name") }} {{ t("environment.name") }}
</span> </span>
<span <span class="min-w-32 w-full truncate text-tiny font-semibold">
class="min-w-[9rem] w-full truncate text-tiny font-semibold"
>
{{ t("environment.value") }} {{ t("environment.value") }}
</span> </span>
</div> </div>
@@ -261,10 +270,10 @@
:key="index" :key="index"
class="flex flex-1 space-x-4" class="flex flex-1 space-x-4"
> >
<span class="min-w-[9rem] w-1/4 truncate text-secondaryLight"> <span class="min-w-32 w-1/4 truncate text-secondaryLight">
{{ variable.key }} {{ variable.key }}
</span> </span>
<span class="min-w-[9rem] w-full truncate text-secondaryLight"> <span class="min-w-32 w-full truncate text-secondaryLight">
{{ variable.value }} {{ variable.value }}
</span> </span>
</div> </div>
@@ -437,11 +446,12 @@ const isEnvActive = (id: string | number) => {
} else { } else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") { if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return selectedEnv.value.index === id return selectedEnv.value.index === id
} else {
return (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnv.value.teamEnvID === id
)
} }
return (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnv.value.teamEnvID === id
)
} }
} }
@@ -486,36 +496,40 @@ const selectedEnv = computed(() => {
name: props.modelValue.environment.environment.name, name: props.modelValue.environment.environment.name,
teamEnvID: props.modelValue.environment.id, teamEnvID: props.modelValue.environment.id,
} }
} else {
return { type: "global", name: "Global" }
} }
return { type: "global", name: "Global" } } else {
} if (selectedEnvironmentIndex.value.type === "MY_ENV") {
if (selectedEnvironmentIndex.value.type === "MY_ENV") { const environment =
const environment = myEnvironments.value[selectedEnvironmentIndex.value.index]
myEnvironments.value[selectedEnvironmentIndex.value.index]
return {
type: "MY_ENV",
index: selectedEnvironmentIndex.value.index,
name: environment.name,
variables: environment.variables,
}
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
return { return {
type: "TEAM_ENV", type: "MY_ENV",
name: teamEnv.environment.name, index: selectedEnvironmentIndex.value.index,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID, name: environment.name,
variables: teamEnv.environment.variables, variables: environment.variables,
} }
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
return {
type: "TEAM_ENV",
name: teamEnv.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
variables: teamEnv.environment.variables,
}
} else {
return { type: "NO_ENV_SELECTED" }
}
} else {
return { type: "NO_ENV_SELECTED" }
} }
return { type: "NO_ENV_SELECTED" }
} }
return { type: "NO_ENV_SELECTED" }
}) })
// Set the selected environment as initial scope value // Set the selected environment as initial scope value
@@ -563,12 +577,13 @@ const envQuickPeekActions = ref<TippyComponent | null>(null)
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} } else {
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
default: default:
return t("error.something_went_wrong") return t("error.something_went_wrong")
}
} }
} }
@@ -577,8 +592,9 @@ const globalEnvs = useReadonlyStream(globalEnv$, [])
const environmentVariables = computed(() => { const environmentVariables = computed(() => {
if (selectedEnv.value.variables) { if (selectedEnv.value.variables) {
return selectedEnv.value.variables return selectedEnv.value.variables
} else {
return []
} }
return []
}) })
const editGlobalEnv = () => { const editGlobalEnv = () => {

View File

@@ -78,13 +78,11 @@
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')" :text="t('empty.environments')"
> >
<template #body> <HoppButtonSecondary
<HoppButtonSecondary :label="`${t('add.new')}`"
:label="`${t('add.new')}`" filled
filled @click="addEnvironmentVariable"
@click="addEnvironmentVariable" />
/>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
@@ -200,8 +198,9 @@ const workingEnv = computed(() => {
type: "MY_ENV", type: "MY_ENV",
index: props.editingEnvironmentIndex, index: props.editingEnvironmentIndex,
}) })
} else {
return null
} }
return null
}) })
const envList = useReadonlyStream(environments$, []) || props.envVars() const envList = useReadonlyStream(environments$, []) || props.envVars()
@@ -227,11 +226,12 @@ const liveEnvs = computed(() => {
return [ return [
...vars.value.map((x) => ({ ...x.env, source: editingName.value! })), ...vars.value.map((x) => ({ ...x.env, source: editingName.value! })),
] ]
} else {
return [
...vars.value.map((x) => ({ ...x.env, source: editingName.value! })),
...globalVars.value.map((x) => ({ ...x, source: "Global" })),
]
} }
return [
...vars.value.map((x) => ({ ...x.env, source: editingName.value! })),
...globalVars.value.map((x) => ({ ...x, source: "Global" })),
]
}) })
watch( watch(

View File

@@ -38,29 +38,27 @@
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')" :text="t('empty.environments')"
> >
<template #body> <div class="flex flex-col items-center space-y-4">
<div class="flex flex-col items-center space-y-4"> <span class="text-center text-secondaryLight">
<span class="text-center text-secondaryLight"> {{ t("environment.import_or_create") }}
{{ t("environment.import_or_create") }} </span>
</span> <div class="flex flex-col items-stretch gap-4">
<div class="flex flex-col items-stretch gap-4"> <HoppButtonPrimary
<HoppButtonPrimary :icon="IconImport"
:icon="IconImport" :label="t('import.title')"
:label="t('import.title')" filled
filled outline
outline @click="displayModalImportExport(true)"
@click="displayModalImportExport(true)" />
/> <HoppButtonSecondary
<HoppButtonSecondary :icon="IconPlus"
:icon="IconPlus" :label="`${t('add.new')}`"
:label="`${t('add.new')}`" filled
filled outline
outline @click="displayModalAdd(true)"
@click="displayModalAdd(true)" />
/>
</div>
</div> </div>
</template> </div>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<EnvironmentsMyDetails <EnvironmentsMyDetails
:show="showModalDetails" :show="showModalDetails"
@@ -70,7 +68,7 @@
@hide-modal="displayModalEdit(false)" @hide-modal="displayModalEdit(false)"
/> />
<EnvironmentsImportExport <EnvironmentsImportExport
v-if="showModalImportExport" :show="showModalImportExport"
environment-type="MY_ENV" environment-type="MY_ENV"
@hide-modal="displayModalImportExport(false)" @hide-modal="displayModalImportExport(false)"
/> />

View File

@@ -81,20 +81,18 @@
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')" :text="t('empty.environments')"
> >
<template #body> <HoppButtonSecondary
<HoppButtonSecondary v-if="isViewer"
v-if="isViewer" disabled
disabled :label="`${t('add.new')}`"
:label="`${t('add.new')}`" filled
filled />
/> <HoppButtonSecondary
<HoppButtonSecondary v-else
v-else :label="`${t('add.new')}`"
:label="`${t('add.new')}`" filled
filled @click="addEnvironmentVariable"
@click="addEnvironmentVariable" />
/>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
@@ -207,8 +205,11 @@ const evnExpandError = computed(() => {
const liveEnvs = computed(() => { const liveEnvs = computed(() => {
if (evnExpandError.value) { if (evnExpandError.value) {
return [] return []
} else {
return [
...vars.value.map((x) => ({ ...x.env, source: editingName.value! })),
]
} }
return [...vars.value.map((x) => ({ ...x.env, source: editingName.value! }))]
}) })
watch( watch(
@@ -337,12 +338,13 @@ const hideModal = () => {
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} } else {
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
default: default:
return t("error.something_went_wrong") return t("error.something_went_wrong")
}
} }
} }
</script> </script>

View File

@@ -184,12 +184,13 @@ const duplicateEnvironments = () => {
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} } else {
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
default: default:
return t("error.something_went_wrong") return t("error.something_went_wrong")
}
} }
} }
</script> </script>

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