Compare commits

..

3 Commits

39 changed files with 3511 additions and 6283 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.1", "@codemirror/language": "^6.9.0",
"@lezer/highlight": "^1.1.6", "@lezer/highlight": "^1.1.6",
"@lezer/lr": "^1.3.13" "@lezer/lr": "^1.3.10"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.5.1", "@lezer/generator": "^1.5.0",
"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

@@ -22,17 +22,17 @@
}, },
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^10.1.0", "@apidevtools/swagger-parser": "^10.1.0",
"@codemirror/autocomplete": "^6.10.2", "@codemirror/autocomplete": "^6.9.0",
"@codemirror/commands": "^6.3.0", "@codemirror/commands": "^6.2.4",
"@codemirror/lang-javascript": "^6.2.1", "@codemirror/lang-javascript": "^6.1.9",
"@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.1", "@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.0",
"@codemirror/search": "^6.5.4", "@codemirror/search": "^6.5.1",
"@codemirror/state": "^6.3.1", "@codemirror/state": "^6.2.1",
"@codemirror/view": "^6.21.3", "@codemirror/view": "^6.16.0",
"@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,6 +42,8 @@
"@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

@@ -0,0 +1,200 @@
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,7 +60,6 @@
<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"
> >
@@ -135,7 +134,6 @@ 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()
@@ -165,8 +163,6 @@ 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,7 +26,6 @@ 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])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$` `${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])$`
), ),
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/])(:[0-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/])$`
), ),
] ]
} }

View File

@@ -6,9 +6,7 @@
"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": [ "files": [ "dist/*" ],
"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",
@@ -35,15 +33,13 @@
"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": "^5.2.2", "typescript": "^4.6.3",
"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,22 +1,14 @@
import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import { InferredEntity, createVersionedEntity } from "verzod" import * as E from "fp-ts/Either"
import V0_VERSION from "./v/0" export type Environment = {
id?: string
export const Environment = createVersionedEntity({ name: string
latestVersion: 0, variables: {
versionMap: { key: string
0: V0_VERSION value: string
}, }[]
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

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

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

View File

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

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

@@ -0,0 +1,43 @@
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,5 +11,3 @@ 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,58 +1,66 @@
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 cloneDeep from "lodash/cloneDeep" import { ValidContentTypes } from "./content-types"
import V0_VERSION from "./v/0" import { HoppRESTAuth } from "./HoppRESTAuth"
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 { export * from "./HoppRESTAuth"
FormDataKeyValue,
HoppRESTReqBodyFormData,
HoppRESTAuth,
HoppRESTAuthAPIKey,
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthNone,
HoppRESTAuthOAuth2,
HoppRESTReqBody,
} from "./v/1"
const versionedObject = z.object({ export const RESTReqSchemaVersion = "1"
// v is a stringified number
v: z.string().regex(/^\d+$/).transform(Number),
})
export const HoppRESTRequest = createVersionedEntity({ export type HoppRESTParam = {
latestVersion: 1, key: string
versionMap: { value: string
0: V0_VERSION, active: boolean
1: V1_VERSION, }
},
getVersion(data) {
// For V1 onwards we have the v string storing the number
const versionCheck = versionedObject.safeParse(data)
if (versionCheck.success) return versionCheck.data.v export type HoppRESTHeader = {
key: string
value: string
active: boolean
}
// For V0 we have to check the schema export type FormDataKeyValue = {
const result = V0_VERSION.schema.safeParse(data) key: string
active: boolean
} & ({ isFile: true; value: Blob[] } | { isFile: false; value: string })
return result.success ? 0 : null export type HoppRESTReqBodyFormData = {
}, contentType: "multipart/form-data"
}) body: FormDataKeyValue[]
}
export type HoppRESTRequest = InferredEntity<typeof HoppRESTRequest> export type HoppRESTReqBody =
| {
contentType: Exclude<ValidContentTypes, "multipart/form-data">
body: string
}
| HoppRESTReqBodyFormData
| {
contentType: null
body: null
}
const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({ export interface 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,
@@ -72,11 +80,6 @@ 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
/** /**
@@ -84,9 +87,6 @@ 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,53 +94,40 @@ 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 ("id" in x && typeof x.id === "string") req.id = x.id if (x.hasOwnProperty("v") && typeof x.v === "string")
req.v = x.v
if ("name" in x && typeof x.name === "string") req.name = x.name if (x.hasOwnProperty("id") && typeof x.id === "string")
req.id = x.id
if ("method" in x && typeof x.method === "string") req.method = x.method if (x.hasOwnProperty("name") && typeof x.name === "string")
req.name = x.name
if ("endpoint" in x && typeof x.endpoint === "string") if (x.hasOwnProperty("method") && typeof x.method === "string")
req.method = x.method
if (x.hasOwnProperty("endpoint") && typeof x.endpoint === "string")
req.endpoint = x.endpoint req.endpoint = x.endpoint
if ("preRequestScript" in x && typeof x.preRequestScript === "string") if (x.hasOwnProperty("preRequestScript") && typeof x.preRequestScript === "string")
req.preRequestScript = x.preRequestScript req.preRequestScript = x.preRequestScript
if ("testScript" in x && typeof x.testScript === "string") if (x.hasOwnProperty("testScript") && typeof x.testScript === "string")
req.testScript = x.testScript req.testScript = x.testScript
if ("body" in x) { if (x.hasOwnProperty("body") && typeof x.body === "object" && !!x.body)
const result = HoppRESTReqBody.safeParse(x.body) req.body = x.body as any // TODO: Deep nested checks
if (result.success) { if (x.hasOwnProperty("auth") && typeof x.auth === "object" && !!x.auth)
req.body = result.data req.auth = x.auth as any // TODO: Deep nested checks
}
}
if ("auth" in x) { if (x.hasOwnProperty("params") && Array.isArray(x.params))
const result = HoppRESTAuth.safeParse(x.auth) req.params = x.params // TODO: Deep nested checks
if (result.success) { if (x.hasOwnProperty("headers") && Array.isArray(x.headers))
req.auth = result.data req.headers = x.headers // TODO: Deep nested checks
}
}
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
@@ -150,51 +137,105 @@ export function makeRESTRequest(
x: Omit<HoppRESTRequest, "v"> x: Omit<HoppRESTRequest, "v">
): HoppRESTRequest { ): HoppRESTRequest {
return { return {
v: RESTReqSchemaVersion,
...x, ...x,
v: RESTReqSchemaVersion,
} }
} }
export function getDefaultRESTRequest(): HoppRESTRequest { export function isHoppRESTRequest(x: any): x is 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 {
v: "1", contentType: "application/json",
endpoint: "https://echo.hoppscotch.io", body: "",
name: "Untitled", }
params: [], }
headers: [],
method: "GET", export function translateToNewRequest(x: any): HoppRESTRequest {
auth: { if (isHoppRESTRequest(x)) {
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: "",
testScript: "",
body: {
contentType: null,
body: null,
},
}
}
/** if (x.auth === "Basic Auth")
* Checks if the given value is a HoppRESTRequest return {
* @param x The value to check authType: "basic",
* authActive: true,
* @deprecated This function is no longer recommended and is only here for legacy reasons username: x.httpUser,
* Use `HoppRESTRequest.is`/`HoppRESTRequest.isLatest` instead. password: x.httpPassword,
*/ }
export function isHoppRESTRequest(x: unknown): x is HoppRESTRequest {
return HoppRESTRequest.isLatest(x)
}
/** if (x.auth === "Bearer Token")
* Safely parses a value into a HoppRESTRequest. return {
* @param x The value to check authType: "bearer",
* authActive: true,
* @deprecated This function is no longer recommended and is only here for token: x.bearerToken,
* legacy reasons. Use `HoppRESTRequest.safeParse` instead. }
*/
export function translateToNewRequest(x: unknown): HoppRESTRequest { return { authType: "none", authActive: true }
const result = HoppRESTRequest.safeParse(x)
return result.type === "ok" ? result.value : getDefaultRESTRequest()
} }

View File

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

@@ -1,209 +0,0 @@
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", "DOM"], "lib": ["esnext"],
"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", "DOM"], "lib": ["esnext"],
"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: "jsdom", testEnvironment: "node",
collectCoverage: true, collectCoverage: true,
setupFilesAfterEnv: ["./jest.setup.ts"], setupFilesAfterEnv: ["./jest.setup.ts"],
} }

View File

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

View File

@@ -147,7 +147,6 @@ 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,7 +39,6 @@
"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",
@@ -51,7 +50,6 @@
"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,40 +1,39 @@
// 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.getCurrentUserStream(), auth.getProbableUserStream(),
auth.getCurrentUser() auth.getProbableUser()
); );
const expandSidebar = () => { const expandSidebar = () => {

View File

@@ -184,71 +184,91 @@ 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')}`);
} }
}); });
}); });
const signInWithGoogle = () => { async function signInWithGoogle() {
signingInWithGoogle.value = true; signingInWithGoogle.value = true;
try { try {
auth.signInUserWithGoogle(); await 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 {
auth.signInUserWithGithub(); await 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;
}; }
const signInWithMicrosoft = () => { async function signInWithMicrosoft() {
signingInWithMicrosoft.value = true; signingInWithMicrosoft.value = true;
try { try {
auth.signInUserWithMicrosoft(); await 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() {
const signInWithEmail = async () => {
signingInWithEmail.value = true; signingInWithEmail.value = true;
try {
await auth.signInWithEmail(form.value.email); await auth
mode.value = 'email-sent'; .signInWithEmail(form.value.email)
setLocalConfig('emailForSignIn', form.value.email); .then(() => {
} catch (e) { mode.value = 'email-sent';
console.error(e); setLocalConfig('emailForSignIn', form.value.email);
toast.error(t('state.email_signin_failure')); })
} .catch((e: any) => {
signingInWithEmail.value = false; console.error(e);
}; toast.error(e.message);
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/Email'; import { Email, EmailCodec } from '~/helpers/backend/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

@@ -0,0 +1,62 @@
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,14 +1,12 @@
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 } from 'vue'; import { Ref, ref, watch } 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.
*/ */
@@ -25,16 +23,22 @@ 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
@@ -47,11 +51,17 @@ 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); const currentUser$ = new BehaviorSubject<HoppUser | null>(null);
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null);
async function logout() {
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 authQuery.logout(); await 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
@@ -59,6 +69,7 @@ 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');
@@ -67,66 +78,142 @@ const signOut = async (reloadWindow = false) => {
}); });
}; };
const getInitialUserDetails = async () => { async function signInUserWithGithubFB() {
const res = await authQuery.getUserDetails(); window.location.href = `${
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);
const setUser = (user: HoppUser | null) => { function setUser(user: HoppUser | null) {
currentUser$.next(user); currentUser$.next(user);
setLocalConfig('login_state', JSON.stringify(user)); probableUser$.next(user);
};
const setInitialUser = async () => { setLocalConfig('login_state', JSON.stringify(user));
}
async function setInitialUser() {
isGettingInitialUser.value = true; isGettingInitialUser.value = true;
const res = await getInitialUserDetails(); const res = await getInitialUserDetails();
if (res.errors?.[0]) { const error = res.errors && res.errors[0];
const [error] = res.errors;
if (error.message === COOKIES_NOT_FOUND) { // no cookies sent. so the user is not logged in
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);
} else if (error.message === UNAUTHORIZED) { await signOut(true);
const isRefreshSuccess = await refreshToken(); isGettingInitialUser.value = false;
if (isRefreshSuccess) {
setInitialUser();
} else {
setUser(null);
signOut(true);
}
} }
} else if (res.data?.me) {
const { uid, displayName, email, photoURL, isAdmin } = res.data.me; return;
}
// 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, uid: hoppBackendUser.uid,
displayName, displayName: hoppBackendUser.displayName,
email, email: hoppBackendUser.email,
photoURL, photoURL: hoppBackendUser.photoURL,
// all our signin methods currently guarantees the email is verified
emailVerified: true, emailVerified: true,
isAdmin, isAdmin: hoppBackendUser.isAdmin,
}; };
if (!hoppUser.isAdmin) { if (!hoppUser.isAdmin) {
hoppUser.isAdmin = await elevateUser(); const 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,
}); });
}
isGettingInitialUser.value = false; return;
}; }
}
const refreshToken = async () => { const refreshToken = async () => {
try { try {
const res = await authQuery.refreshToken(); const res = await axios.get(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`,
{
withCredentials: true,
}
);
authEvents$.next({ authEvents$.next({
event: 'token_refresh', event: 'token_refresh',
}); });
@@ -136,67 +223,157 @@ const refreshToken = async () => {
} }
}; };
const elevateUser = async () => { async function elevateUser() {
const res = await authQuery.elevateUser(); const res = await axios.get(
return Boolean(res.data?.isAdmin); `${import.meta.env.VITE_BACKEND_API_URL}/auth/verify/admin`,
}; {
withCredentials: true,
}
);
const sendMagicLink = async (email: string) => { return !!res.data?.isAdmin;
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,
performAuthInit: () => { getBackendHeaders() {
const currentUser = JSON.parse(getLocalConfig('login_state') ?? 'null'); return {};
currentUser$.next(currentUser); },
return setInitialUser(); getGQLClientOptions() {
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');
}, },
signInUserWithGoogle: () => { async verifyEmailAddress() {
window.location.href = `${ return;
import.meta.env.VITE_BACKEND_API_URL
}/auth/google?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}, },
async signInUserWithGoogle() {
signInUserWithGithub: () => { await signInUserWithGoogleFB();
window.location.href = `${
import.meta.env.VITE_BACKEND_API_URL
}/auth/github?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}, },
async signInUserWithGithub() {
signInUserWithMicrosoft: () => { await signInUserWithGithubFB();
window.location.href = `${ return undefined;
import.meta.env.VITE_BACKEND_API_URL
}/auth/microsoft?redirect_uri=${import.meta.env.VITE_ADMIN_URL}`;
}, },
async signInUserWithMicrosoft() {
signInWithEmailLink: (url: string) => { await signInUserWithMicrosoftFB();
},
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');
return authQuery.signInWithEmailLink(token, deviceIdentifier); await axios.post(
`${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;
}, },
performAuthRefresh: async () => { async performAuthRefresh() {
const isRefreshSuccess = await refreshToken(); const isRefreshSuccess = await refreshToken();
if (isRefreshSuccess) { if (isRefreshSuccess) {
@@ -209,10 +386,12 @@ export const auth = {
} }
}, },
signOutUser: (reloadWindow = false) => signOut(reloadWindow), async signOutUser(reloadWindow = false) {
await signOut(reloadWindow);
},
processMagicLink: async () => { async processMagicLink() {
if (auth.isSignInWithEmailLink(window.location.href)) { if (this.isSignInWithEmailLink(window.location.href)) {
const deviceIdentifier = getLocalConfig('deviceIdentifier'); const deviceIdentifier = getLocalConfig('deviceIdentifier');
if (!deviceIdentifier) { if (!deviceIdentifier) {
@@ -221,7 +400,7 @@ export const auth = {
); );
} }
await auth.signInWithEmailLink(window.location.href); await this.signInWithEmailLink(deviceIdentifier, 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

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

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

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

View File

@@ -1,9 +0,0 @@
/* 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,7 +16,6 @@ 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 () => {
@@ -41,12 +40,12 @@ import { GRAPHQL_UNAUTHORIZED } from './helpers/errors';
async refreshAuth() { async refreshAuth() {
pipe( pipe(
await auth.performAuthRefresh(), await auth.performAuthRefresh(),
O.getOrElseW(() => auth.signOutUser(true)) O.getOrElseW(async () => await 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(async () => { onBeforeMount(() => {
await auth.performAuthInit(); auth.performAuthInit();
}); });
onMounted(async () => { onMounted(async () => {

View File

@@ -21,6 +21,7 @@
"@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",
@@ -80,4 +81,4 @@
"./style.css": "./dist/style.css" "./style.css": "./dist/style.css"
}, },
"types": "./dist/index.d.ts" "types": "./dist/index.d.ts"
} }

8041
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff