Compare commits

..

10 Commits

Author SHA1 Message Date
amk-dev
f98687da7b chore: remove @lezer/highlight from hoppscotch-ui 2023-11-02 20:10:42 +05:30
Andrew Bastin
cbe3e14b47 refactor: versioning and migration mechanism for public data structures (#3457)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-11-02 18:54:16 +05:30
Gaurav K P
01df1663ad fix(common): handle false negatives in url validation (#3465) 2023-11-01 22:23:33 +05:30
Nivedin
abd5288da8 refactor: move sentry to platform (#3451) 2023-11-01 18:17:55 +05:30
Michel Tomas
a89bc473f6 fix(self-hosted/web): add "useCredentials: true" to Vite PWA options (#3460) 2023-11-01 09:46:20 +05:30
Andrew Bastin
57cb59027b chore: bump codemirror dependencies 2023-10-19 13:37:07 +05:30
Joel Jacob Stephen
7a9f0c8756 refactor: improvements to the auth implementation in admin dashboard (#3444)
* refactor: abstract axios queries to a separate helper file

* chore: delete unnecessary file

* chore: remove unnecessary console logs

* refactor: updated urls for api and authquery helpers

* refactor: updated auth implementation

* refactor: use default axios instance

* chore: improve code readability

* refactor: separate instances for rest and gql calls

* refactor: removed async await from functions that do not need them

* refactor: removed probable login and probable user from the auth system

* refactor: better error handling in login component

* chore: deleted unnecessary files and restructured some files

* feat: new errors file with typed error message formats

* refactor: removed unwanted usage of async await

* refactor: optimizing the usage and return of promises in auth flow

* refactor: convey boolean return type in a better way

* chore: apply suggestions

* refactor: handle case when mailcatcher is not active

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: James George <jamesgeorge998001@gmail.com>
2023-10-16 18:14:02 +05:30
Balu Babu
46caf9b198 refactor: removed all instances of rejectOnNotFound in prisma queries (#3377)
* chore: removed rejectOnNotFound property from prisma query in team-enviroment method

* chore: fixed issues with test cases in team-environment module

* chore: changed target of hoppscotch-old-backend service back to prod
2023-10-16 14:04:03 +05:30
Mir Arif Hasan
f5db54484c HBE-266 Update NestJS packages (#3389)
* build: update npm nest packages

* build: removed depricated apollo-server-plugin package

* build: pnpm-lock file added

* build: swc integrated

* Revert "build: swc integrated"

This reverts commit 803a01f38f210dfbcd603665893d29af565c8908.

* feat: upgrade graphql* packages version

* feat: upgrade point release

* feat: update pnpm-lock file
2023-10-16 12:23:55 +05:30
Mir Arif Hasan
8deb6471b9 HBE-270 Test-Case timestamp issue fix in backend (#3415)
test: timestamp issue fix in user-history
2023-10-16 12:11:15 +05:30
48 changed files with 1961 additions and 2392 deletions

View File

@@ -17,12 +17,12 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@codemirror/language": "^6.9.0", "@codemirror/language": "^6.9.1",
"@lezer/highlight": "^1.1.6", "@lezer/highlight": "^1.1.6",
"@lezer/lr": "^1.3.10" "@lezer/lr": "^1.3.13"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.5.0", "@lezer/generator": "^1.5.1",
"mocha": "^9.2.2", "mocha": "^9.2.2",
"rollup": "^3.29.3", "rollup": "^3.29.3",
"rollup-plugin-dts": "^6.0.2", "rollup-plugin-dts": "^6.0.2",

View File

@@ -24,18 +24,17 @@
"do-test": "pnpm run test" "do-test": "pnpm run test"
}, },
"dependencies": { "dependencies": {
"@nestjs-modules/mailer": "^1.8.1", "@apollo/server": "^4.9.4",
"@nestjs/apollo": "^10.1.6", "@nestjs-modules/mailer": "^1.9.1",
"@nestjs/common": "^9.2.1", "@nestjs/apollo": "^12.0.9",
"@nestjs/core": "^9.2.1", "@nestjs/common": "^10.2.6",
"@nestjs/graphql": "^10.1.6", "@nestjs/core": "^10.2.6",
"@nestjs/jwt": "^10.0.1", "@nestjs/graphql": "^12.0.9",
"@nestjs/passport": "^9.0.0", "@nestjs/jwt": "^10.1.1",
"@nestjs/platform-express": "^9.2.1", "@nestjs/passport": "^10.0.2",
"@nestjs/throttler": "^4.0.0", "@nestjs/platform-express": "^10.2.6",
"@nestjs/throttler": "^5.0.0",
"@prisma/client": "^4.16.2", "@prisma/client": "^4.16.2",
"apollo-server-express": "^3.11.1",
"apollo-server-plugin-base": "^3.7.1",
"argon2": "^0.30.3", "argon2": "^0.30.3",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"cookie": "^0.5.0", "cookie": "^0.5.0",
@@ -43,9 +42,9 @@
"express": "^4.17.1", "express": "^4.17.1",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"fp-ts": "^2.13.1", "fp-ts": "^2.13.1",
"graphql": "^15.5.0", "graphql": "^16.8.1",
"graphql-query-complexity": "^0.12.0", "graphql-query-complexity": "^0.12.0",
"graphql-redis-subscriptions": "^2.5.0", "graphql-redis-subscriptions": "^2.6.0",
"graphql-subscriptions": "^2.0.0", "graphql-subscriptions": "^2.0.0",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"io-ts": "^2.2.16", "io-ts": "^2.2.16",
@@ -63,10 +62,11 @@
"rxjs": "^7.6.0" "rxjs": "^7.6.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.1.5", "@nestjs/cli": "^10.1.18",
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^10.2.6",
"@relmify/jest-fp-ts": "^2.0.2", "@relmify/jest-fp-ts": "^2.0.2",
"@types/argon2": "^0.15.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",

View File

@@ -27,12 +27,7 @@ import { AppController } from './app.controller';
buildSchemaOptions: { buildSchemaOptions: {
numberScalarMode: 'integer', numberScalarMode: 'integer',
}, },
cors: {
origin: process.env.WHITELISTED_ORIGINS.split(','),
credentials: true,
},
playground: process.env.PRODUCTION !== 'true', playground: process.env.PRODUCTION !== 'true',
debug: process.env.PRODUCTION !== 'true',
autoSchemaFile: true, autoSchemaFile: true,
installSubscriptionHandlers: true, installSubscriptionHandlers: true,
subscriptions: { subscriptions: {
@@ -62,10 +57,12 @@ import { AppController } from './app.controller';
}), }),
driver: ApolloDriver, driver: ApolloDriver,
}), }),
ThrottlerModule.forRoot({ ThrottlerModule.forRoot([
ttl: +process.env.RATE_LIMIT_TTL, {
limit: +process.env.RATE_LIMIT_MAX, ttl: +process.env.RATE_LIMIT_TTL,
}), limit: +process.env.RATE_LIMIT_MAX,
},
]),
UserModule, UserModule,
AuthModule, AuthModule,
AdminModule, AdminModule,

View File

@@ -93,9 +93,7 @@ export async function emitGQLSchemaFile() {
numberScalarMode: 'integer', numberScalarMode: 'integer',
}); });
const schemaString = printSchema(schema, { const schemaString = printSchema(schema);
commentDescriptions: true,
});
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`); logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);

View File

@@ -3,8 +3,7 @@ import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard { export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
protected getTracker(req: Record<string, any>): string { protected async getTracker(req: Record<string, any>): Promise<string> {
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
// learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#directives
} }
} }

View File

@@ -1,8 +1,9 @@
import { GraphQLSchemaHost } from '@nestjs/graphql'; import { GraphQLSchemaHost } from '@nestjs/graphql';
import { import {
ApolloServerPlugin, ApolloServerPlugin,
BaseContext,
GraphQLRequestListener, GraphQLRequestListener,
} from 'apollo-server-plugin-base'; } from '@apollo/server';
import { Plugin } from '@nestjs/apollo'; import { Plugin } from '@nestjs/apollo';
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import { import {
@@ -17,7 +18,7 @@ const COMPLEXITY_LIMIT = 50;
export class GQLComplexityPlugin implements ApolloServerPlugin { export class GQLComplexityPlugin implements ApolloServerPlugin {
constructor(private gqlSchemaHost: GraphQLSchemaHost) {} constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
async requestDidStart(): Promise<GraphQLRequestListener> { async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
const { schema } = this.gqlSchemaHost; const { schema } = this.gqlSchemaHost;
return { return {

View File

@@ -1,5 +1,5 @@
import { Team, TeamCollection as DBTeamCollection } from '@prisma/client'; import { Team, TeamCollection as DBTeamCollection } from '@prisma/client';
import { mock, mockDeep, mockReset } from 'jest-mock-extended'; import { mockDeep, mockReset } from 'jest-mock-extended';
import { import {
TEAM_COLL_DEST_SAME, TEAM_COLL_DEST_SAME,
TEAM_COLL_INVALID_JSON, TEAM_COLL_INVALID_JSON,
@@ -17,9 +17,6 @@ import { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { TeamCollectionService } from './team-collection.service'; import { TeamCollectionService } from './team-collection.service';
import { TeamCollection } from './team-collection.model';
import { TeamCollectionModule } from './team-collection.module';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>(); const mockPubSub = mockDeep<PubSubService>();

View File

@@ -301,7 +301,7 @@ describe('TeamEnvironmentsService', () => {
describe('createDuplicateEnvironment', () => { describe('createDuplicateEnvironment', () => {
test('should successfully duplicate an existing team environment', async () => { test('should successfully duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment, teamEnvironment,
); );
@@ -322,7 +322,9 @@ describe('TeamEnvironmentsService', () => {
}); });
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError'); mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValue(
'NotFoundError',
);
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
@@ -332,7 +334,7 @@ describe('TeamEnvironmentsService', () => {
}); });
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is updated successfully', async () => { test('should send pubsub message to "team_environment/<teamID>/created" if team environment is updated successfully', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment, teamEnvironment,
); );

View File

@@ -183,11 +183,10 @@ export class TeamEnvironmentsService {
*/ */
async createDuplicateEnvironment(id: string) { async createDuplicateEnvironment(id: string) {
try { try {
const environment = await this.prisma.teamEnvironment.findFirst({ const environment = await this.prisma.teamEnvironment.findFirstOrThrow({
where: { where: {
id: id, id: id,
}, },
rejectOnNotFound: true,
}); });
const result = await this.prisma.teamEnvironment.create({ const result = await this.prisma.teamEnvironment.create({

View File

@@ -142,13 +142,15 @@ describe('UserHistoryService', () => {
}); });
describe('createUserHistory', () => { describe('createUserHistory', () => {
test('Should resolve right and create a REST request to users history and return a `UserHistory` object', async () => { test('Should resolve right and create a REST request to users history and return a `UserHistory` object', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({ mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc', userUid: 'abc',
id: '1', id: '1',
request: [{}], request: [{}],
responseMetadata: [{}], responseMetadata: [{}],
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}); });
@@ -158,7 +160,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]), request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]), responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}; };
@@ -172,13 +174,15 @@ describe('UserHistoryService', () => {
).toEqualRight(userHistory); ).toEqualRight(userHistory);
}); });
test('Should resolve right and create a GQL request to users history and return a `UserHistory` object', async () => { test('Should resolve right and create a GQL request to users history and return a `UserHistory` object', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({ mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc', userUid: 'abc',
id: '1', id: '1',
request: [{}], request: [{}],
responseMetadata: [{}], responseMetadata: [{}],
reqType: ReqType.GQL, reqType: ReqType.GQL,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}); });
@@ -188,7 +192,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]), request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]), responseMetadata: JSON.stringify([{}]),
reqType: ReqType.GQL, reqType: ReqType.GQL,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}; };
@@ -212,13 +216,15 @@ describe('UserHistoryService', () => {
).toEqualLeft(USER_HISTORY_INVALID_REQ_TYPE); ).toEqualLeft(USER_HISTORY_INVALID_REQ_TYPE);
}); });
test('Should create a GQL request to users history and publish a created subscription', async () => { test('Should create a GQL request to users history and publish a created subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({ mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc', userUid: 'abc',
id: '1', id: '1',
request: [{}], request: [{}],
responseMetadata: [{}], responseMetadata: [{}],
reqType: ReqType.GQL, reqType: ReqType.GQL,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}); });
@@ -228,7 +234,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]), request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]), responseMetadata: JSON.stringify([{}]),
reqType: ReqType.GQL, reqType: ReqType.GQL,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}; };
@@ -245,13 +251,15 @@ describe('UserHistoryService', () => {
); );
}); });
test('Should create a REST request to users history and publish a created subscription', async () => { test('Should create a REST request to users history and publish a created subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({ mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc', userUid: 'abc',
id: '1', id: '1',
request: [{}], request: [{}],
responseMetadata: [{}], responseMetadata: [{}],
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}); });
@@ -261,7 +269,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]), request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]), responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}; };
@@ -323,13 +331,15 @@ describe('UserHistoryService', () => {
).toEqualLeft(USER_HISTORY_NOT_FOUND); ).toEqualLeft(USER_HISTORY_NOT_FOUND);
}); });
test('Should star/unstar a request in the history and publish a updated subscription', async () => { test('Should star/unstar a request in the history and publish a updated subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.findFirst.mockResolvedValueOnce({ mockPrisma.userHistory.findFirst.mockResolvedValueOnce({
userUid: 'abc', userUid: 'abc',
id: '1', id: '1',
request: [{}], request: [{}],
responseMetadata: [{}], responseMetadata: [{}],
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn,
isStarred: false, isStarred: false,
}); });
@@ -339,7 +349,7 @@ describe('UserHistoryService', () => {
request: [{}], request: [{}],
responseMetadata: [{}], responseMetadata: [{}],
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn,
isStarred: true, isStarred: true,
}); });
@@ -349,7 +359,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]), request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]), responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST, reqType: ReqType.REST,
executedOn: new Date(), executedOn,
isStarred: true, isStarred: true,
}; };

View File

@@ -22,17 +22,17 @@
}, },
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^10.1.0", "@apidevtools/swagger-parser": "^10.1.0",
"@codemirror/autocomplete": "^6.9.0", "@codemirror/autocomplete": "^6.10.2",
"@codemirror/commands": "^6.2.4", "@codemirror/commands": "^6.3.0",
"@codemirror/lang-javascript": "^6.1.9", "@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.0", "@codemirror/language": "^6.9.1",
"@codemirror/legacy-modes": "^6.3.3", "@codemirror/legacy-modes": "^6.3.3",
"@codemirror/lint": "^6.4.0", "@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.1", "@codemirror/search": "^6.5.4",
"@codemirror/state": "^6.2.1", "@codemirror/state": "^6.3.1",
"@codemirror/view": "^6.16.0", "@codemirror/view": "^6.21.3",
"@fontsource-variable/inter": "^5.0.8", "@fontsource-variable/inter": "^5.0.8",
"@fontsource-variable/material-symbols-rounded": "^5.0.7", "@fontsource-variable/material-symbols-rounded": "^5.0.7",
"@fontsource-variable/roboto-mono": "^5.0.9", "@fontsource-variable/roboto-mono": "^5.0.9",
@@ -42,8 +42,6 @@
"@hoppscotch/ui": "workspace:^", "@hoppscotch/ui": "workspace:^",
"@hoppscotch/vue-toasted": "^0.1.0", "@hoppscotch/vue-toasted": "^0.1.0",
"@lezer/highlight": "^1.1.6", "@lezer/highlight": "^1.1.6",
"@sentry/tracing": "^7.64.0",
"@sentry/vue": "^7.64.0",
"@urql/core": "^4.1.1", "@urql/core": "^4.1.1",
"@urql/devtools": "^2.0.3", "@urql/devtools": "^2.0.3",
"@urql/exchange-auth": "^2.1.6", "@urql/exchange-auth": "^2.1.6",

View File

@@ -1,200 +0,0 @@
import { HoppModule } from "."
import * as Sentry from "@sentry/vue"
import { BrowserTracing } from "@sentry/tracing"
import { Route } from "@sentry/vue/types/router"
import { RouteLocationNormalized, Router } from "vue-router"
import { settingsStore } from "~/newstore/settings"
import { App } from "vue"
import { APP_IS_IN_DEV_MODE } from "~/helpers/dev"
import { gqlClientError$ } from "~/helpers/backend/GQLClient"
import { platform } from "~/platform"
/**
* The tag names we allow giving to Sentry
*/
type SentryTag = "BACKEND_OPERATIONS"
interface SentryVueRouter {
onError: (fn: (err: Error) => void) => void
beforeEach: (fn: (to: Route, from: Route, next: () => void) => void) => void
}
function normalizedRouteToSentryRoute(route: RouteLocationNormalized): Route {
return {
matched: route.matched,
// route.params' type translates just to a fancy version of this, hence assertion
params: route.params as Route["params"],
path: route.path,
// route.query's type translates just to a fancy version of this, hence assertion
query: route.query as Route["query"],
name: route.name,
}
}
function getInstrumentationVueRouter(router: Router): SentryVueRouter {
return <SentryVueRouter>{
onError: router.onError,
beforeEach(func) {
router.beforeEach((to, from, next) => {
func(
normalizedRouteToSentryRoute(to),
normalizedRouteToSentryRoute(from),
next
)
})
},
}
}
let sentryActive = false
function initSentry(dsn: string, router: Router, app: App) {
Sentry.init({
app,
dsn,
release: import.meta.env.VITE_SENTRY_RELEASE_TAG ?? undefined,
environment: APP_IS_IN_DEV_MODE
? "dev"
: import.meta.env.VITE_SENTRY_ENVIRONMENT,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(
getInstrumentationVueRouter(router)
),
// TODO: We may want to limit this later on
tracingOrigins: [new URL(import.meta.env.VITE_BACKEND_GQL_URL).origin],
}),
],
tracesSampleRate: 0.8,
})
sentryActive = true
}
function deinitSentry() {
Sentry.close()
sentryActive = false
}
/**
* Reports a set of related errors to Sentry
* @param errs The errors to report
* @param tag The tag for the errord
* @param extraTags Additional tag data to add
* @param extras Extra information to attach
*/
function reportErrors(
errs: Error[],
tag: SentryTag,
extraTags: Record<string, string | number | boolean> | null = null,
extras: any = undefined
) {
if (sentryActive) {
Sentry.withScope((scope) => {
scope.setTag("tag", tag)
if (extraTags) {
Object.entries(extraTags).forEach(([key, value]) => {
scope.setTag(key, value)
})
}
if (extras !== null && extras === undefined) scope.setExtras(extras)
scope.addAttachment({
filename: "extras-dump.json",
data: JSON.stringify(extras),
contentType: "application/json",
})
errs.forEach((err) => Sentry.captureException(err))
})
}
}
/**
* Reports a specific error to Sentry
* @param err The error to report
* @param tag The tag for the error
* @param extraTags Additional tag data to add
* @param extras Extra information to attach
*/
function reportError(
err: Error,
tag: SentryTag,
extraTags: Record<string, string | number | boolean> | null = null,
extras: any = undefined
) {
reportErrors([err], tag, extraTags, extras)
}
/**
* Subscribes to events occuring in various subsystems in the app
* for personalized error reporting
*/
function subscribeToAppEventsForReporting() {
gqlClientError$.subscribe((ev) => {
switch (ev.type) {
case "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT":
reportErrors(ev.errors, "BACKEND_OPERATIONS", { from: ev.type })
break
case "CLIENT_REPORTED_ERROR":
reportError(
ev.error,
"BACKEND_OPERATIONS",
{ from: ev.type },
{ op: ev.op }
)
break
case "GQL_CLIENT_REPORTED_ERROR":
reportError(
new Error("Backend Query Failed"),
"BACKEND_OPERATIONS",
{ opType: ev.opType },
{
opResult: ev.opResult,
}
)
break
}
})
}
/**
* Subscribe to app system events for adding
* additional data tags for the error reporting
*/
function subscribeForAppDataTags() {
const currentUser$ = platform.auth.getCurrentUserStream()
currentUser$.subscribe((user) => {
if (sentryActive) {
Sentry.setTag("user_logged_in", !!user)
}
})
}
export default <HoppModule>{
onRouterInit(app, router) {
if (!import.meta.env.VITE_SENTRY_DSN) {
console.log(
"Sentry tracing is not enabled because 'VITE_SENTRY_DSN' env is not defined"
)
return
}
if (settingsStore.value.TELEMETRY_ENABLED) {
initSentry(import.meta.env.VITE_SENTRY_DSN, router, app)
}
settingsStore.subject$.subscribe(({ TELEMETRY_ENABLED }) => {
if (!TELEMETRY_ENABLED && sentryActive) {
deinitSentry()
} else if (TELEMETRY_ENABLED && !sentryActive) {
initSentry(import.meta.env.VITE_SENTRY_DSN!, router, app)
}
})
subscribeToAppEventsForReporting()
subscribeForAppDataTags()
},
}

View File

@@ -60,6 +60,7 @@
<div class="py-4 space-y-4"> <div class="py-4 space-y-4">
<div class="flex items-center"> <div class="flex items-center">
<HoppSmartToggle <HoppSmartToggle
v-if="hasPlatformTelemetry"
:on="TELEMETRY_ENABLED" :on="TELEMETRY_ENABLED"
@change="showConfirmModal" @change="showConfirmModal"
> >
@@ -134,6 +135,7 @@ import { InterceptorService } from "~/services/interceptor.service"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array" import * as A from "fp-ts/Array"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
const colorMode = useColorMode() const colorMode = useColorMode()
@@ -163,6 +165,8 @@ const TELEMETRY_ENABLED = useSetting("TELEMETRY_ENABLED")
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION") const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT") const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
const hasPlatformTelemetry = Boolean(platform.platformFeatureFlags.hasTelemetry)
const confirmRemove = ref(false) const confirmRemove = ref(false)
const proxySettings = computed(() => ({ const proxySettings = computed(() => ({

View File

@@ -26,6 +26,7 @@ export type PlatformDef = {
additionalInspectors?: InspectorsPlatformDef additionalInspectors?: InspectorsPlatformDef
platformFeatureFlags: { platformFeatureFlags: {
exportAsGIST: boolean exportAsGIST: boolean
hasTelemetry: boolean
} }
} }

View File

@@ -1,10 +1,10 @@
function generateREForProtocol(protocol) { function generateREForProtocol(protocol) {
return [ return [
new RegExp( new RegExp(
`${protocol}(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$` `${protocol}(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$`
), ),
new RegExp( new RegExp(
`${protocol}(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9/])$` `${protocol}(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9/])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$`
), ),
] ]
} }

View File

@@ -6,7 +6,9 @@
"main": "dist/hoppscotch-data.cjs", "main": "dist/hoppscotch-data.cjs",
"module": "dist/hoppscotch-data.js", "module": "dist/hoppscotch-data.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": [ "dist/*" ], "files": [
"dist/*"
],
"scripts": { "scripts": {
"build:code": "vite build", "build:code": "vite build",
"build:decl": "tsc --project tsconfig.decl.json", "build:decl": "tsc --project tsconfig.decl.json",
@@ -33,13 +35,15 @@
"homepage": "https://github.com/hoppscotch/hoppscotch#readme", "homepage": "https://github.com/hoppscotch/hoppscotch#readme",
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.14.181", "@types/lodash": "^4.14.181",
"typescript": "^4.6.3", "typescript": "^5.2.2",
"vite": "^3.2.3" "vite": "^3.2.3"
}, },
"dependencies": { "dependencies": {
"fp-ts": "^2.11.10", "fp-ts": "^2.11.10",
"io-ts": "^2.2.16", "io-ts": "^2.2.16",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"parser-ts": "^0.6.16" "parser-ts": "^0.6.16",
"verzod": "^0.1.1",
"zod": "^3.22.2"
} }
} }

View File

@@ -1,14 +1,22 @@
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
import { InferredEntity, createVersionedEntity } from "verzod"
export type Environment = { import V0_VERSION from "./v/0"
id?: string
name: string export const Environment = createVersionedEntity({
variables: { latestVersion: 0,
key: string versionMap: {
value: string 0: V0_VERSION
}[] },
} getVersion(x) {
return V0_VERSION.schema.safeParse(x).success
? 0
: null
}
})
export type Environment = InferredEntity<typeof Environment>
const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<<myVariable>>" const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<<myVariable>>"

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { defineVersion } from "verzod"
export const V0_SCHEMA = z.object({
id: z.optional(z.string()),
name: z.string(),
variables: z.array(
z.object({
key: z.string(),
value: z.string(),
})
)
})
export default defineVersion({
initial: true,
schema: V0_SCHEMA
})

View File

@@ -1,43 +0,0 @@
export type HoppGQLAuthNone = {
authType: "none"
}
export type HoppGQLAuthBasic = {
authType: "basic"
username: string
password: string
}
export type HoppGQLAuthBearer = {
authType: "bearer"
token: string
}
export type HoppGQLAuthOAuth2 = {
authType: "oauth-2"
token: string
oidcDiscoveryURL: string
authURL: string
accessTokenURL: string
clientID: string
scope: string
}
export type HoppGQLAuthAPIKey = {
authType: "api-key"
key: string
value: string
addTo: string
}
export type HoppGQLAuth = { authActive: boolean } & (
| HoppGQLAuthNone
| HoppGQLAuthBasic
| HoppGQLAuthBearer
| HoppGQLAuthOAuth2
| HoppGQLAuthAPIKey
)

View File

@@ -1,51 +1,75 @@
import { HoppGQLAuth } from "./HoppGQLAuth" import { InferredEntity, createVersionedEntity } from "verzod"
import { z } from "zod"
import V1_VERSION from "./v/1"
import V2_VERSION from "./v/2"
export * from "./HoppGQLAuth" export { GQLHeader } from "./v/1"
export {
HoppGQLAuth,
HoppGQLAuthAPIKey,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthNone,
HoppGQLAuthOAuth2,
} from "./v/2"
export const GQL_REQ_SCHEMA_VERSION = 2 export const GQL_REQ_SCHEMA_VERSION = 2
export type GQLHeader = { const versionedObject = z.object({
key: string v: z.number(),
value: string })
active: boolean
}
export type HoppGQLRequest = { export const HoppGQLRequest = createVersionedEntity({
id?: string latestVersion: 2,
v: number versionMap: {
name: string 1: V1_VERSION,
url: string 2: V2_VERSION,
headers: GQLHeader[] },
query: string getVersion(x) {
variables: string const result = versionedObject.safeParse(x)
auth: HoppGQLAuth
}
export function translateToGQLRequest(x: any): HoppGQLRequest { return result.success ? result.data.v : null
if (x.v && x.v === GQL_REQ_SCHEMA_VERSION) return x },
})
// Old request export type HoppGQLRequest = InferredEntity<typeof HoppGQLRequest>
const name = x.name ?? "Untitled"
const url = x.url ?? "" const DEFAULT_QUERY = `
const headers = x.headers ?? [] query Request {
const query = x.query ?? "" method
const variables = x.variables ?? [] url
const auth = x.auth ?? { headers {
authType: "none", key
authActive: true, value
} }
}`.trim()
export function getDefaultGQLRequest(): HoppGQLRequest {
return { return {
v: GQL_REQ_SCHEMA_VERSION, v: GQL_REQ_SCHEMA_VERSION,
name, name: "Untitled",
url, url: "https://echo.hoppscotch.io/graphql",
headers, headers: [],
query, variables: `
variables, {
auth "id": "1"
}`.trim(),
query: DEFAULT_QUERY,
auth: {
authType: "none",
authActive: true,
},
} }
} }
/**
* @deprecated This function is deprecated. Use `HoppGQLRequest` instead.
*/
export function translateToGQLRequest(x: unknown): HoppGQLRequest {
const result = HoppGQLRequest.safeParse(x)
return result.type === "ok" ? result.value : getDefaultGQLRequest()
}
export function makeGQLRequest(x: Omit<HoppGQLRequest, "v">): HoppGQLRequest { export function makeGQLRequest(x: Omit<HoppGQLRequest, "v">): HoppGQLRequest {
return { return {
v: GQL_REQ_SCHEMA_VERSION, v: GQL_REQ_SCHEMA_VERSION,

View File

@@ -0,0 +1,24 @@
import { z } from "zod"
import { defineVersion } from "verzod"
export const GQLHeader = z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
})
export type GQLHeader = z.infer<typeof GQLHeader>
export const V1_SCHEMA = z.object({
v: z.literal(1),
name: z.string(),
url: z.string(),
headers: z.array(GQLHeader),
query: z.string(),
variables: z.string(),
})
export default defineVersion({
initial: true,
schema: V1_SCHEMA
})

View File

@@ -0,0 +1,91 @@
import { z } from "zod"
import { defineVersion } from "verzod"
import { GQLHeader, V1_SCHEMA } from "./1"
export const HoppGQLAuthNone = z.object({
authType: z.literal("none")
})
export type HoppGQLAuthNone = z.infer<typeof HoppGQLAuthNone>
export const HoppGQLAuthBasic = z.object({
authType: z.literal("basic"),
username: z.string(),
password: z.string()
})
export type HoppGQLAuthBasic = z.infer<typeof HoppGQLAuthBasic>
export const HoppGQLAuthBearer = z.object({
authType: z.literal("bearer"),
token: z.string()
})
export type HoppGQLAuthBearer = z.infer<typeof HoppGQLAuthBearer>
export const HoppGQLAuthOAuth2 = z.object({
authType: z.literal("oauth-2"),
token: z.string(),
oidcDiscoveryURL: z.string(),
authURL: z.string(),
accessTokenURL: z.string(),
clientID: z.string(),
scope: z.string()
})
export type HoppGQLAuthOAuth2 = z.infer<typeof HoppGQLAuthOAuth2>
export const HoppGQLAuthAPIKey = z.object({
authType: z.literal("api-key"),
key: z.string(),
value: z.string(),
addTo: z.string()
})
export type HoppGQLAuthAPIKey = z.infer<typeof HoppGQLAuthAPIKey>
export const HoppGQLAuth = z.discriminatedUnion("authType", [
HoppGQLAuthNone,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthOAuth2,
HoppGQLAuthAPIKey
]).and(
z.object({
authActive: z.boolean()
})
)
export type HoppGQLAuth = z.infer<typeof HoppGQLAuth>
const V2_SCHEMA = z.object({
id: z.optional(z.string()),
v: z.literal(2),
name: z.string(),
url: z.string(),
headers: z.array(GQLHeader),
query: z.string(),
variables: z.string(),
auth: HoppGQLAuth
})
export default defineVersion({
initial: false,
schema: V2_SCHEMA,
up(old: z.infer<typeof V1_SCHEMA>) {
return <z.infer<typeof V2_SCHEMA>>{
...old,
v: 2,
auth: {
authActive: true,
authType: "none",
}
}
}
})

View File

@@ -1,43 +0,0 @@
export type HoppRESTAuthNone = {
authType: "none"
}
export type HoppRESTAuthBasic = {
authType: "basic"
username: string
password: string
}
export type HoppRESTAuthBearer = {
authType: "bearer"
token: string
}
export type HoppRESTAuthOAuth2 = {
authType: "oauth-2"
token: string
oidcDiscoveryURL: string
authURL: string
accessTokenURL: string
clientID: string
scope: string
}
export type HoppRESTAuthAPIKey = {
authType: "api-key"
key: string
value: string
addTo: string
}
export type HoppRESTAuth = { authActive: boolean } & (
| HoppRESTAuthNone
| HoppRESTAuthBasic
| HoppRESTAuthBearer
| HoppRESTAuthOAuth2
| HoppRESTAuthAPIKey
)

View File

@@ -11,3 +11,5 @@ export const knownContentTypes = {
} }
export type ValidContentTypes = keyof typeof knownContentTypes export type ValidContentTypes = keyof typeof knownContentTypes
export const ValidContentTypesList = Object.keys(knownContentTypes) as ValidContentTypes[]

View File

@@ -1,66 +1,58 @@
import cloneDeep from "lodash/cloneDeep"
import * as Eq from "fp-ts/Eq" import * as Eq from "fp-ts/Eq"
import * as S from "fp-ts/string" import * as S from "fp-ts/string"
import { ValidContentTypes } from "./content-types" import cloneDeep from "lodash/cloneDeep"
import { HoppRESTAuth } from "./HoppRESTAuth" import V0_VERSION from "./v/0"
import V1_VERSION from "./v/1"
import { createVersionedEntity, InferredEntity } from "verzod"
import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq" import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq"
import {
HoppRESTAuth,
HoppRESTReqBody,
HoppRESTHeaders,
HoppRESTParams,
} from "./v/1"
import { z } from "zod"
export * from "./content-types" export * from "./content-types"
export * from "./HoppRESTAuth" export {
FormDataKeyValue,
HoppRESTReqBodyFormData,
HoppRESTAuth,
HoppRESTAuthAPIKey,
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthNone,
HoppRESTAuthOAuth2,
HoppRESTReqBody,
} from "./v/1"
export const RESTReqSchemaVersion = "1" const versionedObject = z.object({
// v is a stringified number
v: z.string().regex(/^\d+$/).transform(Number),
})
export type HoppRESTParam = { export const HoppRESTRequest = createVersionedEntity({
key: string latestVersion: 1,
value: string versionMap: {
active: boolean 0: V0_VERSION,
} 1: V1_VERSION,
},
getVersion(data) {
// For V1 onwards we have the v string storing the number
const versionCheck = versionedObject.safeParse(data)
export type HoppRESTHeader = { if (versionCheck.success) return versionCheck.data.v
key: string
value: string
active: boolean
}
export type FormDataKeyValue = { // For V0 we have to check the schema
key: string const result = V0_VERSION.schema.safeParse(data)
active: boolean
} & ({ isFile: true; value: Blob[] } | { isFile: false; value: string })
export type HoppRESTReqBodyFormData = { return result.success ? 0 : null
contentType: "multipart/form-data" },
body: FormDataKeyValue[] })
}
export type HoppRESTReqBody = export type HoppRESTRequest = InferredEntity<typeof HoppRESTRequest>
| {
contentType: Exclude<ValidContentTypes, "multipart/form-data">
body: string
}
| HoppRESTReqBodyFormData
| {
contentType: null
body: null
}
export interface HoppRESTRequest { const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
v: string
id?: string // Firebase Firestore ID
name: string
method: string
endpoint: string
params: HoppRESTParam[]
headers: HoppRESTHeader[]
preRequestScript: string
testScript: string
auth: HoppRESTAuth
body: HoppRESTReqBody
}
export const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
id: undefinedEq(S.Eq), id: undefinedEq(S.Eq),
v: S.Eq, v: S.Eq,
auth: lodashIsEqualEq, auth: lodashIsEqualEq,
@@ -80,6 +72,11 @@ export const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
testScript: S.Eq, testScript: S.Eq,
}) })
export const RESTReqSchemaVersion = "1"
export type HoppRESTParam = HoppRESTRequest["params"][number]
export type HoppRESTHeader = HoppRESTRequest["headers"][number]
export const isEqualHoppRESTRequest = HoppRESTRequestEq.equals export const isEqualHoppRESTRequest = HoppRESTRequestEq.equals
/** /**
@@ -87,6 +84,9 @@ export const isEqualHoppRESTRequest = HoppRESTRequestEq.equals
* If we fail to detect certain bits, we just resolve it to the default value * If we fail to detect certain bits, we just resolve it to the default value
* @param x The value to extract REST Request data from * @param x The value to extract REST Request data from
* @param defaultReq The default REST Request to source from * @param defaultReq The default REST Request to source from
*
* @deprecated Usage of this function is no longer recommended and is only here
* for legacy reasons and will be removed
*/ */
export function safelyExtractRESTRequest( export function safelyExtractRESTRequest(
x: unknown, x: unknown,
@@ -94,40 +94,53 @@ export function safelyExtractRESTRequest(
): HoppRESTRequest { ): HoppRESTRequest {
const req = cloneDeep(defaultReq) const req = cloneDeep(defaultReq)
// TODO: A cleaner way to do this ?
if (!!x && typeof x === "object") { if (!!x && typeof x === "object") {
if (x.hasOwnProperty("v") && typeof x.v === "string") if ("id" in x && typeof x.id === "string") req.id = x.id
req.v = x.v
if (x.hasOwnProperty("id") && typeof x.id === "string") if ("name" in x && typeof x.name === "string") req.name = x.name
req.id = x.id
if (x.hasOwnProperty("name") && typeof x.name === "string") if ("method" in x && typeof x.method === "string") req.method = x.method
req.name = x.name
if (x.hasOwnProperty("method") && typeof x.method === "string") if ("endpoint" in x && typeof x.endpoint === "string")
req.method = x.method
if (x.hasOwnProperty("endpoint") && typeof x.endpoint === "string")
req.endpoint = x.endpoint req.endpoint = x.endpoint
if (x.hasOwnProperty("preRequestScript") && typeof x.preRequestScript === "string") if ("preRequestScript" in x && typeof x.preRequestScript === "string")
req.preRequestScript = x.preRequestScript req.preRequestScript = x.preRequestScript
if (x.hasOwnProperty("testScript") && typeof x.testScript === "string") if ("testScript" in x && typeof x.testScript === "string")
req.testScript = x.testScript req.testScript = x.testScript
if (x.hasOwnProperty("body") && typeof x.body === "object" && !!x.body) if ("body" in x) {
req.body = x.body as any // TODO: Deep nested checks const result = HoppRESTReqBody.safeParse(x.body)
if (x.hasOwnProperty("auth") && typeof x.auth === "object" && !!x.auth) if (result.success) {
req.auth = x.auth as any // TODO: Deep nested checks req.body = result.data
}
}
if (x.hasOwnProperty("params") && Array.isArray(x.params)) if ("auth" in x) {
req.params = x.params // TODO: Deep nested checks const result = HoppRESTAuth.safeParse(x.auth)
if (x.hasOwnProperty("headers") && Array.isArray(x.headers)) if (result.success) {
req.headers = x.headers // TODO: Deep nested checks req.auth = result.data
}
}
if ("params" in x) {
const result = HoppRESTParams.safeParse(x.params)
if (result.success) {
req.params = result.data
}
}
if ("headers" in x) {
const result = HoppRESTHeaders.safeParse(x.headers)
if (result.success) {
req.headers = result.data
}
}
} }
return req return req
@@ -137,105 +150,51 @@ export function makeRESTRequest(
x: Omit<HoppRESTRequest, "v"> x: Omit<HoppRESTRequest, "v">
): HoppRESTRequest { ): HoppRESTRequest {
return { return {
...x,
v: RESTReqSchemaVersion, v: RESTReqSchemaVersion,
...x,
} }
} }
export function isHoppRESTRequest(x: any): x is HoppRESTRequest { export function getDefaultRESTRequest(): HoppRESTRequest {
return x && typeof x === "object" && "v" in x
}
function parseRequestBody(x: any): HoppRESTReqBody {
if (x.contentType === "application/json") {
return {
contentType: "application/json",
body: x.rawParams,
}
}
return { return {
contentType: "application/json", v: "1",
body: "", endpoint: "https://echo.hoppscotch.io",
} name: "Untitled",
} params: [],
headers: [],
export function translateToNewRequest(x: any): HoppRESTRequest { method: "GET",
if (isHoppRESTRequest(x)) { auth: {
return x
} else {
// Old format
const endpoint: string = `${x?.url ?? ""}${x?.path ?? ""}`
const headers: HoppRESTHeader[] = x?.headers ?? []
// Remove old keys from params
const params: HoppRESTParam[] = (x?.params ?? []).map(
({
key,
value,
active,
}: {
key: string
value: string
active: boolean
}) => ({
key,
value,
active,
})
)
const name = x?.name ?? "Untitled request"
const method = x?.method ?? ""
const preRequestScript = x?.preRequestScript ?? ""
const testScript = x?.testScript ?? ""
const body = parseRequestBody(x)
const auth = parseOldAuth(x)
const result: HoppRESTRequest = {
name,
endpoint,
headers,
params,
method,
preRequestScript,
testScript,
body,
auth,
v: RESTReqSchemaVersion,
}
if (x.id) result.id = x.id
return result
}
}
export function parseOldAuth(x: any): HoppRESTAuth {
if (!x.auth || x.auth === "None")
return {
authType: "none", authType: "none",
authActive: true, authActive: true,
} },
preRequestScript: "",
if (x.auth === "Basic Auth") testScript: "",
return { body: {
authType: "basic", contentType: null,
authActive: true, body: null,
username: x.httpUser, },
password: x.httpPassword, }
} }
if (x.auth === "Bearer Token") /**
return { * Checks if the given value is a HoppRESTRequest
authType: "bearer", * @param x The value to check
authActive: true, *
token: x.bearerToken, * @deprecated This function is no longer recommended and is only here for legacy reasons
} * Use `HoppRESTRequest.is`/`HoppRESTRequest.isLatest` instead.
*/
return { authType: "none", authActive: true } export function isHoppRESTRequest(x: unknown): x is HoppRESTRequest {
return HoppRESTRequest.isLatest(x)
}
/**
* Safely parses a value into a HoppRESTRequest.
* @param x The value to check
*
* @deprecated This function is no longer recommended and is only here for
* legacy reasons. Use `HoppRESTRequest.safeParse` instead.
*/
export function translateToNewRequest(x: unknown): HoppRESTRequest {
const result = HoppRESTRequest.safeParse(x)
return result.type === "ok" ? result.value : getDefaultRESTRequest()
} }

View File

@@ -0,0 +1,39 @@
import { defineVersion } from "verzod"
import { z } from "zod"
export const V0_SCHEMA = z.object({
id: z.optional(z.string()), // Firebase Firestore ID
url: z.string(),
path: z.string(),
headers: z.array(
z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
})
),
params: z.array(
z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
})
),
name: z.string(),
method: z.string(),
preRequestScript: z.string(),
testScript: z.string(),
contentType: z.string(),
body: z.string(),
rawParams: z.optional(z.string()),
auth: z.optional(z.string()),
httpUser: z.optional(z.string()),
httpPassword: z.optional(z.string()),
bearerToken: z.optional(z.string()),
})
export default defineVersion({
initial: true,
schema: V0_SCHEMA
})

View File

@@ -0,0 +1,209 @@
import { defineVersion } from "verzod"
import { z } from "zod"
import { V0_SCHEMA } from "./0"
export const FormDataKeyValue = z.object({
key: z.string(),
active: z.boolean()
}).and(
z.union([
z.object({
isFile: z.literal(true),
value: z.array(z.instanceof(Blob))
}),
z.object({
isFile: z.literal(false),
value: z.string()
})
])
)
export type FormDataKeyValue = z.infer<typeof FormDataKeyValue>
export const HoppRESTReqBodyFormData = z.object({
contentType: z.literal("multipart/form-data"),
body: z.array(FormDataKeyValue)
})
export type HoppRESTReqBodyFormData = z.infer<typeof HoppRESTReqBodyFormData>
export const HoppRESTReqBody = z.union([
z.object({
contentType: z.literal(null),
body: z.literal(null)
}),
z.object({
contentType: z.literal("multipart/form-data"),
body: FormDataKeyValue
}),
z.object({
contentType: z.union([
z.literal("application/json"),
z.literal("application/ld+json"),
z.literal("application/hal+json"),
z.literal("application/vnd.api+json"),
z.literal("application/xml"),
z.literal("application/x-www-form-urlencoded"),
z.literal("text/html"),
z.literal("text/plain"),
]),
body: z.string()
})
])
export type HoppRESTReqBody = z.infer<typeof HoppRESTReqBody>
export const HoppRESTAuthNone = z.object({
authType: z.literal("none")
})
export type HoppRESTAuthNone = z.infer<typeof HoppRESTAuthNone>
export const HoppRESTAuthBasic = z.object({
authType: z.literal("basic"),
username: z.string(),
password: z.string(),
})
export type HoppRESTAuthBasic = z.infer<typeof HoppRESTAuthBasic>
export const HoppRESTAuthBearer = z.object({
authType: z.literal("bearer"),
token: z.string(),
})
export type HoppRESTAuthBearer = z.infer<typeof HoppRESTAuthBearer>
export const HoppRESTAuthOAuth2 = z.object({
authType: z.literal("oauth-2"),
token: z.string(),
oidcDiscoveryURL: z.string(),
authURL: z.string(),
accessTokenURL: z.string(),
clientID: z.string(),
scope: z.string(),
})
export type HoppRESTAuthOAuth2 = z.infer<typeof HoppRESTAuthOAuth2>
export const HoppRESTAuthAPIKey = z.object({
authType: z.literal("api-key"),
key: z.string(),
value: z.string(),
addTo: z.string(),
})
export type HoppRESTAuthAPIKey = z.infer<typeof HoppRESTAuthAPIKey>
export const HoppRESTAuth = z.discriminatedUnion("authType", [
HoppRESTAuthNone,
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthOAuth2,
HoppRESTAuthAPIKey
]).and(
z.object({
authActive: z.boolean(),
})
)
export type HoppRESTAuth = z.infer<typeof HoppRESTAuth>
export const HoppRESTParams = z.array(
z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
})
)
export type HoppRESTParams = z.infer<typeof HoppRESTParams>
export const HoppRESTHeaders = z.array(
z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
})
)
export type HoppRESTHeaders = z.infer<typeof HoppRESTHeaders>
const V1_SCHEMA = z.object({
v: z.literal("1"),
id: z.optional(z.string()), // Firebase Firestore ID
name: z.string(),
method: z.string(),
endpoint: z.string(),
params: HoppRESTParams,
headers: HoppRESTHeaders,
preRequestScript: z.string(),
testScript: z.string(),
auth: HoppRESTAuth,
body: HoppRESTReqBody
})
function parseRequestBody(x: z.infer<typeof V0_SCHEMA>): z.infer<typeof V1_SCHEMA>["body"] {
return {
contentType: "application/json",
body: x.contentType === "application/json" ? x.rawParams ?? "" : "",
}
}
export function parseOldAuth(x: z.infer<typeof V0_SCHEMA>): z.infer<typeof V1_SCHEMA>["auth"] {
if (!x.auth || x.auth === "None")
return {
authType: "none",
authActive: true,
}
if (x.auth === "Basic Auth")
return {
authType: "basic",
authActive: true,
username: x.httpUser ?? "",
password: x.httpPassword ?? "",
}
if (x.auth === "Bearer Token")
return {
authType: "bearer",
authActive: true,
token: x.bearerToken ?? "",
}
return { authType: "none", authActive: true }
}
export default defineVersion({
initial: false,
schema: V1_SCHEMA,
up(old: z.infer<typeof V0_SCHEMA>) {
const { url, path, headers, params, name, method, preRequestScript, testScript } = old
const endpoint = `${url}${path}`
const body = parseRequestBody(old)
const auth = parseOldAuth(old)
const result: z.infer<typeof V1_SCHEMA> = {
v: "1",
endpoint,
headers,
params,
name,
method,
preRequestScript,
testScript,
body,
auth,
}
if (old.id) result.id = old.id
return result
},
})

View File

@@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"target": "es2017", "target": "es2017",
"module": "esnext", "module": "esnext",
"lib": ["esnext"], "lib": ["esnext", "DOM"],
"moduleResolution": "node", "moduleResolution": "node",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,

View File

@@ -2,13 +2,13 @@
"compilerOptions": { "compilerOptions": {
"target": "es2017", "target": "es2017",
"module": "esnext", "module": "esnext",
"lib": ["esnext"], "lib": ["esnext", "DOM"],
"moduleResolution": "node", "moduleResolution": "node",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"strictNullChecks": true, "strictNullChecks": true,
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true
}, },
"include": ["src/*.ts"] "include": ["src/*.ts"]
} }

View File

@@ -1,6 +1,6 @@
module.exports = { module.exports = {
preset: "ts-jest", preset: "ts-jest",
testEnvironment: "node", testEnvironment: "jsdom",
collectCoverage: true, collectCoverage: true,
setupFilesAfterEnv: ["./jest.setup.ts"], setupFilesAfterEnv: ["./jest.setup.ts"],
} }

View File

@@ -38,5 +38,6 @@ createHoppApp("#app", {
], ],
platformFeatureFlags: { platformFeatureFlags: {
exportAsGIST: false, exportAsGIST: false,
hasTelemetry: false,
}, },
}) })

View File

@@ -147,6 +147,7 @@ export default defineConfig({
}, },
}), }),
VitePWA({ VitePWA({
useCredentials: true,
manifest: { manifest: {
name: APP_INFO.name, name: APP_INFO.name,
short_name: APP_INFO.name, short_name: APP_INFO.name,

View File

@@ -39,6 +39,7 @@
"delete_user_success": "User deleted successfully!!", "delete_user_success": "User deleted successfully!!",
"email": "Email", "email": "Email",
"email_failure": "Failed to send invitation", "email_failure": "Failed to send invitation",
"email_signin_failure": "Failed to login with Email",
"email_success": "Email invitation sent successfully", "email_success": "Email invitation sent successfully",
"enter_team_email": "Please enter email of team owner!!", "enter_team_email": "Please enter email of team owner!!",
"error": "Something went wrong", "error": "Something went wrong",
@@ -50,6 +51,7 @@
"logout": "Logout", "logout": "Logout",
"magic_link_sign_in": "Click on the link to sign in.", "magic_link_sign_in": "Click on the link to sign in.",
"magic_link_success": "We sent a magic link to", "magic_link_success": "We sent a magic link to",
"microsoft_signin_failure": "Failed to login with Microsoft",
"non_admin_logged_in": "Logged in as non admin user.", "non_admin_logged_in": "Logged in as non admin user.",
"non_admin_login": "You are logged in. But you're not an admin", "non_admin_login": "You are logged in. But you're not an admin",
"privacy_policy": "Privacy Policy", "privacy_policy": "Privacy Policy",

View File

@@ -1,39 +1,40 @@
// generated by unplugin-vue-components // generated by unplugin-vue-components
// We suggest you to commit this file into source control // We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'; import '@vue/runtime-core'
export {}; export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AppHeader: typeof import('./components/app/Header.vue')['default']; AppHeader: typeof import('./components/app/Header.vue')['default']
AppLogin: typeof import('./components/app/Login.vue')['default']; AppLogin: typeof import('./components/app/Login.vue')['default']
AppLogout: typeof import('./components/app/Logout.vue')['default']; AppLogout: typeof import('./components/app/Logout.vue')['default']
AppModal: typeof import('./components/app/Modal.vue')['default']; AppModal: typeof import('./components/app/Modal.vue')['default']
AppSidebar: typeof import('./components/app/Sidebar.vue')['default']; AppSidebar: typeof import('./components/app/Sidebar.vue')['default']
AppToast: typeof import('./components/app/Toast.vue')['default']; AppToast: typeof import('./components/app/Toast.vue')['default']
DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default']; DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']; HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']; HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']; HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']; HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']; HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']; HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']; HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']; HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']; HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']; HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']; IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']; IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']; TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsDetails: typeof import('./components/teams/Details.vue')['default']; TeamsDetails: typeof import('./components/teams/Details.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']; TeamsInvite: typeof import('./components/teams/Invite.vue')['default']
TeamsMembers: typeof import('./components/teams/Members.vue')['default']; TeamsMembers: typeof import('./components/teams/Members.vue')['default']
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default']; TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default']
TeamsTable: typeof import('./components/teams/Table.vue')['default']; TeamsTable: typeof import('./components/teams/Table.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']; Tippy: typeof import('vue-tippy')['Tippy']
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']; UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']
UsersTable: typeof import('./components/users/Table.vue')['default']; UsersTable: typeof import('./components/users/Table.vue')['default']
} }
} }

View File

@@ -89,8 +89,8 @@ const t = useI18n();
const { isOpen, isExpanded } = useSidebar(); const { isOpen, isExpanded } = useSidebar();
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
auth.getProbableUserStream(), auth.getCurrentUserStream(),
auth.getProbableUser() auth.getCurrentUser()
); );
const expandSidebar = () => { const expandSidebar = () => {

View File

@@ -184,91 +184,71 @@ onMounted(() => {
subscribeToStream(currentUser$, (user) => { subscribeToStream(currentUser$, (user) => {
if (user && !user.isAdmin) { if (user && !user.isAdmin) {
nonAdminUser.value = true; nonAdminUser.value = true;
toast.error(`${t('state.non_admin_login')}`); toast.error(t('state.non_admin_login'));
} }
}); });
}); });
async function signInWithGoogle() { const signInWithGoogle = () => {
signingInWithGoogle.value = true; signingInWithGoogle.value = true;
try { try {
await auth.signInUserWithGoogle(); auth.signInUserWithGoogle();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
/* toast.error(t('state.google_signin_failure'));
A auth/account-exists-with-different-credential Firebase error wont happen between Google and any other providers
Seems Google account overwrites accounts of other providers https://github.com/firebase/firebase-android-sdk/issues/25
*/
toast.error(`${t('state.google_signin_failure')}`);
} }
signingInWithGoogle.value = false; signingInWithGoogle.value = false;
} };
async function signInWithGithub() {
const signInWithGithub = () => {
signingInWithGitHub.value = true; signingInWithGitHub.value = true;
try { try {
await auth.signInUserWithGithub(); auth.signInUserWithGithub();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
/* toast.error(t('state.github_signin_failure'));
A auth/account-exists-with-different-credential Firebase error wont happen between Google and any other providers
Seems Google account overwrites accounts of other providers https://github.com/firebase/firebase-android-sdk/issues/25
*/
toast.error(`${t('state.github_signin_failure')}`);
} }
signingInWithGitHub.value = false; signingInWithGitHub.value = false;
} };
async function signInWithMicrosoft() { const signInWithMicrosoft = () => {
signingInWithMicrosoft.value = true; signingInWithMicrosoft.value = true;
try { try {
await auth.signInUserWithMicrosoft(); auth.signInUserWithMicrosoft();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
/* toast.error(t('state.microsoft_signin_failure'));
A auth/account-exists-with-different-credential Firebase error wont happen between MS with Google or Github
If a Github account exists and user then logs in with MS email we get a "Something went wrong toast" and console errors and MS replaces GH as only provider.
The error messages are as follows:
FirebaseError: Firebase: Error (auth/popup-closed-by-user).
@firebase/auth: Auth (9.6.11): INTERNAL ASSERTION FAILED: Pending promise was never set
They may be related to https://github.com/firebase/firebaseui-web/issues/947
*/
toast.error(`${t('state.error')}`);
} }
signingInWithMicrosoft.value = false; signingInWithMicrosoft.value = false;
} };
async function signInWithEmail() {
signingInWithEmail.value = true;
await auth const signInWithEmail = async () => {
.signInWithEmail(form.value.email) signingInWithEmail.value = true;
.then(() => { try {
mode.value = 'email-sent'; await auth.signInWithEmail(form.value.email);
setLocalConfig('emailForSignIn', form.value.email); mode.value = 'email-sent';
}) setLocalConfig('emailForSignIn', form.value.email);
.catch((e: any) => { } catch (e) {
console.error(e); console.error(e);
toast.error(e.message); toast.error(t('state.email_signin_failure'));
signingInWithEmail.value = false; }
}) signingInWithEmail.value = false;
.finally(() => { };
signingInWithEmail.value = false;
});
}
const logout = async () => { const logout = async () => {
try { try {
await auth.signOutUser(); await auth.signOutUser();
window.location.reload(); window.location.reload();
toast.success(`${t('state.logged_out')}`); toast.success(t('state.logged_out'));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
toast.error(`${t('state.error')}`); toast.error(t('state.error'));
} }
}; };
</script> </script>

View File

@@ -200,7 +200,7 @@ import {
} from '../../helpers/backend/graphql'; } from '../../helpers/backend/graphql';
import { useToast } from '~/composables/toast'; import { useToast } from '~/composables/toast';
import { useMutation, useQuery } from '@urql/vue'; import { useMutation, useQuery } from '@urql/vue';
import { Email, EmailCodec } from '~/helpers/backend/Email'; import { Email, EmailCodec } from '~/helpers/Email';
import IconTrash from '~icons/lucide/trash'; import IconTrash from '~icons/lucide/trash';
import IconPlus from '~icons/lucide/plus'; import IconPlus from '~icons/lucide/plus';
import IconCircleDot from '~icons/lucide/circle-dot'; import IconCircleDot from '~icons/lucide/circle-dot';

View File

@@ -1,62 +0,0 @@
import { platform } from '~/platform';
import { AuthEvent, HoppUser } from '~/platform/auth';
import { Subscription } from 'rxjs';
import { onBeforeUnmount, onMounted, watch, WatchStopHandle } from 'vue';
import { useReadonlyStream } from './stream';
/**
* A Vue composable function that is called when the auth status
* is being updated to being logged in (fired multiple times),
* this is also called on component mount if the login
* was already resolved before mount.
*/
export function onLoggedIn(exec: (user: HoppUser) => void) {
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
);
let watchStop: WatchStopHandle | null = null;
onMounted(() => {
if (currentUser.value) exec(currentUser.value);
watchStop = watch(currentUser, (newVal, prev) => {
if (prev === null && newVal !== null) {
exec(newVal);
}
});
});
onBeforeUnmount(() => {
watchStop?.();
});
}
/**
* A Vue composable function that calls its param function
* when a new event (login, logout etc.) happens in
* the auth system.
*
* NOTE: Unlike `onLoggedIn` for which the callback will be called once on mount with the current state,
* here the callback will only be called on authentication event occurances.
* You might want to check the auth state from an `onMounted` hook or something
* if you want to access the initial state
*
* @param func A function which accepts an event
*/
export function onAuthEvent(func: (ev: AuthEvent) => void) {
const authEvents$ = platform.auth.getAuthEventsStream();
let sub: Subscription | null = null;
onMounted(() => {
sub = authEvents$.subscribe((ev) => {
func(ev);
});
});
onBeforeUnmount(() => {
sub?.unsubscribe();
});
}

View File

@@ -1,12 +1,14 @@
import axios from 'axios';
import { BehaviorSubject, Subject } from 'rxjs'; import { BehaviorSubject, Subject } from 'rxjs';
import { import {
getLocalConfig, getLocalConfig,
removeLocalConfig, removeLocalConfig,
setLocalConfig, setLocalConfig,
} from './localpersistence'; } from './localpersistence';
import { Ref, ref, watch } from 'vue'; import { Ref, ref } from 'vue';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import authQuery from './backend/rest/authQuery';
import { COOKIES_NOT_FOUND, UNAUTHORIZED } from './errors';
/** /**
* A common (and required) set of fields that describe a user. * A common (and required) set of fields that describe a user.
*/ */
@@ -23,22 +25,16 @@ export type HoppUser = {
/** URL to the profile picture of the user */ /** URL to the profile picture of the user */
photoURL: string | null; photoURL: string | null;
// Regarding `provider` and `accessToken`:
// The current implementation and use case for these 2 fields are super weird due to legacy.
// Currrently these fields are only basically populated for Github Auth as we need the access token issued
// by it to implement Gist submission. I would really love refactor to make this thing more sane.
/** Name of the provider authenticating (NOTE: See notes on `platform/auth.ts`) */ /** Name of the provider authenticating (NOTE: See notes on `platform/auth.ts`) */
provider?: string; provider?: string;
/** Access Token for the auth of the user against the given `provider`. */ /** Access Token for the auth of the user against the given `provider`. */
accessToken?: string; accessToken?: string;
emailVerified: boolean; emailVerified: boolean;
/** Flag to check for admin status */
isAdmin: boolean; isAdmin: boolean;
}; };
export type AuthEvent = export type AuthEvent =
| { event: 'probable_login'; user: HoppUser } // We have previous login state, but the app is waiting for authentication
| { event: 'login'; user: HoppUser } // We are authenticated | { event: 'login'; user: HoppUser } // We are authenticated
| { event: 'logout' } // No authentication and we have no previous state | { event: 'logout' } // No authentication and we have no previous state
| { event: 'token_refresh' }; // We have previous login state, but the app is waiting for authentication | { event: 'token_refresh' }; // We have previous login state, but the app is waiting for authentication
@@ -51,17 +47,11 @@ export type GithubSignInResult =
export const authEvents$ = new Subject< export const authEvents$ = new Subject<
AuthEvent | { event: 'token_refresh' } AuthEvent | { event: 'token_refresh' }
>(); >();
const currentUser$ = new BehaviorSubject<HoppUser | null>(null);
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null);
async function logout() { const currentUser$ = new BehaviorSubject<HoppUser | null>(null);
await axios.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`, {
withCredentials: true,
});
}
const signOut = async (reloadWindow = false) => { const signOut = async (reloadWindow = false) => {
await logout(); await authQuery.logout();
// Reload the window if both `access_token` and `refresh_token`is invalid // Reload the window if both `access_token` and `refresh_token`is invalid
// there by the user is taken to the login page // there by the user is taken to the login page
@@ -69,7 +59,6 @@ const signOut = async (reloadWindow = false) => {
window.location.reload(); window.location.reload();
} }
probableUser$.next(null);
currentUser$.next(null); currentUser$.next(null);
removeLocalConfig('login_state'); removeLocalConfig('login_state');
@@ -78,142 +67,66 @@ const signOut = async (reloadWindow = false) => {
}); });
}; };
async function signInUserWithGithubFB() { const getInitialUserDetails = async () => {
window.location.href = `${ const res = await authQuery.getUserDetails();
import.meta.env.VITE_BACKEND_API_URL
}/auth/github?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}
async function signInUserWithGoogleFB() {
window.location.href = `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/google?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}
async function signInUserWithMicrosoftFB() {
window.location.href = `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/microsoft?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}
async function getInitialUserDetails() {
const res = await axios.post<{
data?: {
me?: {
uid: string;
displayName: string;
email: string;
photoURL: string;
isAdmin: boolean;
createdOn: string;
// emailVerified: boolean
};
};
errors?: Array<{
message: string;
}>;
}>(
`${import.meta.env.VITE_BACKEND_GQL_URL}`,
{
query: `query Me {
me {
uid
displayName
email
photoURL
isAdmin
createdOn
}
}`,
},
{
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
}
);
return res.data; return res.data;
} };
const isGettingInitialUser: Ref<null | boolean> = ref(null); const isGettingInitialUser: Ref<null | boolean> = ref(null);
function setUser(user: HoppUser | null) { const setUser = (user: HoppUser | null) => {
currentUser$.next(user); currentUser$.next(user);
probableUser$.next(user);
setLocalConfig('login_state', JSON.stringify(user)); setLocalConfig('login_state', JSON.stringify(user));
} };
async function setInitialUser() { const setInitialUser = async () => {
isGettingInitialUser.value = true; isGettingInitialUser.value = true;
const res = await getInitialUserDetails(); const res = await getInitialUserDetails();
const error = res.errors && res.errors[0]; if (res.errors?.[0]) {
const [error] = res.errors;
// no cookies sent. so the user is not logged in if (error.message === COOKIES_NOT_FOUND) {
if (error && error.message === 'auth/cookies_not_found') {
setUser(null);
isGettingInitialUser.value = false;
return;
}
// cookies sent, but it is expired, we need to refresh the token
if (error && error.message === 'Unauthorized') {
const isRefreshSuccess = await refreshToken();
if (isRefreshSuccess) {
setInitialUser();
} else {
setUser(null); setUser(null);
await signOut(true); } else if (error.message === UNAUTHORIZED) {
isGettingInitialUser.value = false; const isRefreshSuccess = await refreshToken();
if (isRefreshSuccess) {
setInitialUser();
} else {
setUser(null);
signOut(true);
}
} }
} else if (res.data?.me) {
return; const { uid, displayName, email, photoURL, isAdmin } = res.data.me;
}
// no errors, we have a valid user
if (res.data && res.data.me) {
const hoppBackendUser = res.data.me;
const hoppUser: HoppUser = { const hoppUser: HoppUser = {
uid: hoppBackendUser.uid, uid,
displayName: hoppBackendUser.displayName, displayName,
email: hoppBackendUser.email, email,
photoURL: hoppBackendUser.photoURL, photoURL,
// all our signin methods currently guarantees the email is verified
emailVerified: true, emailVerified: true,
isAdmin: hoppBackendUser.isAdmin, isAdmin,
}; };
if (!hoppUser.isAdmin) { if (!hoppUser.isAdmin) {
const isAdmin = await elevateUser(); hoppUser.isAdmin = await elevateUser();
hoppUser.isAdmin = isAdmin;
} }
setUser(hoppUser); setUser(hoppUser);
isGettingInitialUser.value = false;
authEvents$.next({ authEvents$.next({
event: 'login', event: 'login',
user: hoppUser, user: hoppUser,
}); });
return;
} }
}
isGettingInitialUser.value = false;
};
const refreshToken = async () => { const refreshToken = async () => {
try { try {
const res = await axios.get( const res = await authQuery.refreshToken();
`${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`,
{
withCredentials: true,
}
);
authEvents$.next({ authEvents$.next({
event: 'token_refresh', event: 'token_refresh',
}); });
@@ -223,157 +136,67 @@ const refreshToken = async () => {
} }
}; };
async function elevateUser() { const elevateUser = async () => {
const res = await axios.get( const res = await authQuery.elevateUser();
`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify/admin`, return Boolean(res.data?.isAdmin);
{ };
withCredentials: true,
}
);
return !!res.data?.isAdmin; const sendMagicLink = async (email: string) => {
} const res = await authQuery.sendMagicLink(email);
if (!res.data?.deviceIdentifier) {
async function sendMagicLink(email: string) {
const res = await axios.post(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/signin?origin=admin`,
{
email,
},
{
withCredentials: true,
}
);
if (res.data && res.data.deviceIdentifier) {
setLocalConfig('deviceIdentifier', res.data.deviceIdentifier);
} else {
throw new Error('test: does not get device identifier'); throw new Error('test: does not get device identifier');
} }
setLocalConfig('deviceIdentifier', res.data.deviceIdentifier);
return res.data; return res.data;
} };
export const auth = { export const auth = {
getCurrentUserStream: () => currentUser$, getCurrentUserStream: () => currentUser$,
getAuthEventsStream: () => authEvents$, getAuthEventsStream: () => authEvents$,
getProbableUserStream: () => probableUser$,
getCurrentUser: () => currentUser$.value, getCurrentUser: () => currentUser$.value,
getProbableUser: () => probableUser$.value,
getBackendHeaders() { performAuthInit: () => {
return {}; const currentUser = JSON.parse(getLocalConfig('login_state') ?? 'null');
}, currentUser$.next(currentUser);
getGQLClientOptions() { return setInitialUser();
return {
fetchOptions: {
credentials: 'include',
},
};
}, },
/** signInWithEmail: (email: string) => sendMagicLink(email),
* it is not possible for us to know if the current cookie is expired because we cannot access http-only cookies from js
* hence just returning if the currentUser$ has a value associated with it
*/
willBackendHaveAuthError() {
return !currentUser$.value;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onBackendGQLClientShouldReconnect(func: () => void) {
authEvents$.subscribe((event) => {
if (
event.event == 'login' ||
event.event == 'logout' ||
event.event == 'token_refresh'
) {
func();
}
});
},
/** isSignInWithEmailLink: (url: string) => {
* we cannot access our auth cookies from javascript, so leaving this as null
*/
getDevOptsBackendIDToken() {
return null;
},
async performAuthInit() {
const probableUser = JSON.parse(getLocalConfig('login_state') ?? 'null');
probableUser$.next(probableUser);
await setInitialUser();
},
waitProbableLoginToConfirm() {
return new Promise<void>((resolve, reject) => {
if (this.getCurrentUser()) {
resolve();
}
if (!probableUser$.value) reject(new Error('no_probable_user'));
const unwatch = watch(isGettingInitialUser, (val) => {
if (val === true || val === false) {
resolve();
unwatch();
}
});
});
},
async signInWithEmail(email: string) {
await sendMagicLink(email);
},
isSignInWithEmailLink(url: string) {
const urlObject = new URL(url); const urlObject = new URL(url);
const searchParams = new URLSearchParams(urlObject.search); const searchParams = new URLSearchParams(urlObject.search);
return Boolean(searchParams.get('token'));
return !!searchParams.get('token');
}, },
async verifyEmailAddress() { signInUserWithGoogle: () => {
return; window.location.href = `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/google?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}, },
async signInUserWithGoogle() {
await signInUserWithGoogleFB(); signInUserWithGithub: () => {
window.location.href = `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/github?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}, },
async signInUserWithGithub() {
await signInUserWithGithubFB(); signInUserWithMicrosoft: () => {
return undefined; window.location.href = `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/microsoft?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}, },
async signInUserWithMicrosoft() {
await signInUserWithMicrosoftFB(); signInWithEmailLink: (url: string) => {
},
async signInWithEmailLink(email: string, url: string) {
const urlObject = new URL(url); const urlObject = new URL(url);
const searchParams = new URLSearchParams(urlObject.search); const searchParams = new URLSearchParams(urlObject.search);
const token = searchParams.get('token'); const token = searchParams.get('token');
const deviceIdentifier = getLocalConfig('deviceIdentifier'); const deviceIdentifier = getLocalConfig('deviceIdentifier');
await axios.post( return authQuery.signInWithEmailLink(token, deviceIdentifier);
`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`,
{
token: token,
deviceIdentifier,
},
{
withCredentials: true,
}
);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async setEmailAddress(_email: string) {
return;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async setDisplayName(name: string) {
return;
}, },
async performAuthRefresh() { performAuthRefresh: async () => {
const isRefreshSuccess = await refreshToken(); const isRefreshSuccess = await refreshToken();
if (isRefreshSuccess) { if (isRefreshSuccess) {
@@ -386,12 +209,10 @@ export const auth = {
} }
}, },
async signOutUser(reloadWindow = false) { signOutUser: (reloadWindow = false) => signOut(reloadWindow),
await signOut(reloadWindow);
},
async processMagicLink() { processMagicLink: async () => {
if (this.isSignInWithEmailLink(window.location.href)) { if (auth.isSignInWithEmailLink(window.location.href)) {
const deviceIdentifier = getLocalConfig('deviceIdentifier'); const deviceIdentifier = getLocalConfig('deviceIdentifier');
if (!deviceIdentifier) { if (!deviceIdentifier) {
@@ -400,7 +221,7 @@ export const auth = {
); );
} }
await this.signInWithEmailLink(deviceIdentifier, window.location.href); await auth.signInWithEmailLink(window.location.href);
removeLocalConfig('deviceIdentifier'); removeLocalConfig('deviceIdentifier');
window.location.href = import.meta.env.VITE_ADMIN_URL; window.location.href = import.meta.env.VITE_ADMIN_URL;

View File

@@ -0,0 +1,20 @@
import axios from 'axios';
const baseConfig = {
headers: {
'Content-type': 'application/json',
},
withCredentials: true,
};
const gqlApi = axios.create({
...baseConfig,
baseURL: import.meta.env.VITE_BACKEND_GQL_URL,
});
const restApi = axios.create({
...baseConfig,
baseURL: import.meta.env.VITE_BACKEND_API_URL,
});
export { gqlApi, restApi };

View File

@@ -0,0 +1,32 @@
import { gqlApi, restApi } from '~/helpers/axiosConfig';
export default {
getUserDetails: () =>
gqlApi.post('', {
query: `query Me {
me {
uid
displayName
email
photoURL
isAdmin
createdOn
}
}`,
}),
refreshToken: () => restApi.get('/auth/refresh'),
elevateUser: () => restApi.get('/auth/verify/admin'),
sendMagicLink: (email: string) =>
restApi.post('/auth/signin?origin=admin', {
email,
}),
signInWithEmailLink: (
token: string | null,
deviceIdentifier: string | null
) =>
restApi.post('/auth/verify', {
token,
deviceIdentifier,
}),
logout: () => restApi.get('/auth/logout'),
};

View File

@@ -1,3 +0,0 @@
export const throwError = (message: string): never => {
throw new Error(message)
}

View File

@@ -0,0 +1,9 @@
/* No cookies were found in the auth request
* (AuthService)
*/
export const COOKIES_NOT_FOUND = 'auth/cookies_not_found' as const;
export const UNAUTHORIZED = 'Unauthorized' as const;
// Sometimes the backend returns Unauthorized error message as follows:
export const GRAPHQL_UNAUTHORIZED = '[GraphQL] Unauthorized' as const;

View File

@@ -16,6 +16,7 @@ import { HOPP_MODULES } from './modules';
import { auth } from './helpers/auth'; import { auth } from './helpers/auth';
import { pipe } from 'fp-ts/function'; import { pipe } from 'fp-ts/function';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { GRAPHQL_UNAUTHORIZED } from './helpers/errors';
// Top-level await is not available in our targets // Top-level await is not available in our targets
(async () => { (async () => {
@@ -40,12 +41,12 @@ import * as O from 'fp-ts/Option';
async refreshAuth() { async refreshAuth() {
pipe( pipe(
await auth.performAuthRefresh(), await auth.performAuthRefresh(),
O.getOrElseW(async () => await auth.signOutUser(true)) O.getOrElseW(() => auth.signOutUser(true))
); );
}, },
didAuthError(error, _operation) { didAuthError(error, _operation) {
return error.message === '[GraphQL] Unauthorized'; return error.message === GRAPHQL_UNAUTHORIZED;
}, },
}; };
}), }),

View File

@@ -13,8 +13,8 @@ import { auth } from '~/helpers/auth';
const signingInWithEmail = ref(false); const signingInWithEmail = ref(false);
const error = ref(null); const error = ref(null);
onBeforeMount(() => { onBeforeMount(async () => {
auth.performAuthInit(); await auth.performAuthInit();
}); });
onMounted(async () => { onMounted(async () => {

View File

@@ -21,7 +21,6 @@
"@fontsource-variable/material-symbols-rounded": "^5.0.5", "@fontsource-variable/material-symbols-rounded": "^5.0.5",
"@fontsource-variable/roboto-mono": "^5.0.6", "@fontsource-variable/roboto-mono": "^5.0.6",
"@hoppscotch/vue-toasted": "^0.1.0", "@hoppscotch/vue-toasted": "^0.1.0",
"@lezer/highlight": "^1.0.0",
"@vitejs/plugin-legacy": "^2.3.0", "@vitejs/plugin-legacy": "^2.3.0",
"@vueuse/core": "^8.7.5", "@vueuse/core": "^8.7.5",
"fp-ts": "^2.12.1", "fp-ts": "^2.12.1",
@@ -81,4 +80,4 @@
"./style.css": "./dist/style.css" "./style.css": "./dist/style.css"
}, },
"types": "./dist/index.d.ts" "types": "./dist/index.d.ts"
} }

2503
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff