Browse Source

feat: selfhost auth frontend (#15)

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Akash K 2 years ago
parent
commit
757d1add5b

+ 1 - 0
.env.example

@@ -21,6 +21,7 @@ VITE_SHORTCODE_BASE_URL=https://hopp.sh
 # Backend URLs
 VITE_BACKEND_GQL_URL=https://api.hoppscotch.io/graphql
 VITE_BACKEND_WS_URL=wss://api.hoppscotch.io/graphql
+VITE_BACKEND_API_URL=https://api.hoppscotch.io/v1
 
 # Sentry (Optional)
 # VITE_SENTRY_DSN: <Sentry DSN here>

+ 3 - 2
packages/hoppscotch-common/src/platform/auth.ts

@@ -119,10 +119,11 @@ export type AuthPlatformDef = {
   onBackendGQLClientShouldReconnect: (func: () => void) => void
 
   /**
-   * provide the client options for GqlClient
+   * Called by the platform to provide additional/different config options when
+   * setting up the URQL based GQLCLient instance
    * @returns
    */
-  getGQLClientOptions?: () => ClientOptions
+  getGQLClientOptions?: () => Partial<ClientOptions>
 
   /**
    * Returns the string content that should be returned when the user selects to

+ 64 - 0
packages/hoppscotch-selfhost-web/.eslintrc.cjs

@@ -0,0 +1,64 @@
+/* eslint-env node */
+require("@rushstack/eslint-patch/modern-module-resolution")
+
+module.exports = {
+  root: true,
+  env: {
+    browser: true,
+    node: true,
+    jest: true,
+  },
+  parserOptions: {
+    sourceType: "module",
+    requireConfigFile: false,
+  },
+  extends: [
+    "@vue/typescript/recommended",
+    "plugin:vue/vue3-recommended",
+    "plugin:prettier/recommended",
+  ],
+  ignorePatterns: [
+    "static/**/*",
+    "./helpers/backend/graphql.ts",
+    "**/*.d.ts",
+    "types/**/*",
+  ],
+  plugins: ["vue", "prettier"],
+  // add your custom rules here
+  rules: {
+    semi: [2, "never"],
+    "import/named": "off", // because, named import issue with typescript see: https://github.com/typescript-eslint/typescript-eslint/issues/154
+    "no-console": "off",
+    "no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
+    "prettier/prettier":
+      process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
+    "vue/multi-word-component-names": "off",
+    "vue/no-side-effects-in-computed-properties": "off",
+    "import/no-named-as-default": "off",
+    "import/no-named-as-default-member": "off",
+    "@typescript-eslint/no-unused-vars":
+      process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
+    "@typescript-eslint/no-non-null-assertion": "off",
+    "@typescript-eslint/no-explicit-any": "off",
+    "import/default": "off",
+    "no-undef": "off",
+    // localStorage block
+    "no-restricted-globals": [
+      "error",
+      {
+        name: "localStorage",
+        message:
+          "Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
+      },
+    ],
+    // window.localStorage block
+    "no-restricted-syntax": [
+      "error",
+      {
+        selector: "CallExpression[callee.object.property.name='localStorage']",
+        message:
+          "Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
+      },
+    ],
+  },
+}

+ 27 - 0
packages/hoppscotch-selfhost-web/.gitignore

@@ -0,0 +1,27 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Sitemap Generation Artifacts (see vite.config.ts)
+.sitemap-gen

+ 26 - 0
packages/hoppscotch-selfhost-web/index.html

@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>Hoppscotch - Open source API development ecosystem</title>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="apple-touch-icon" href="/icon.png" />
+  </head>
+  <body>
+    <div id="app"></div>
+    <script>
+      // Shims to make swagger-parser package work
+      window.global = window
+    </script>
+    <script type="module">
+      import { Buffer } from "buffer"
+      import process from "process"
+
+      // // Shims to make postman-collection work
+      window.Buffer = Buffer
+      window.process = process
+    </script>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 118 - 0
packages/hoppscotch-selfhost-web/meta.ts

@@ -0,0 +1,118 @@
+import { IHTMLTag } from "vite-plugin-html-config"
+
+export const APP_INFO = {
+  name: "Hoppscotch",
+  shortDescription: "Open source API development ecosystem",
+  description:
+    "Helps you create requests faster, saving precious time on development.",
+  keywords:
+    "hoppscotch, hopp scotch, hoppscotch online, hoppscotch app, postwoman, postwoman chrome, postwoman online, postwoman for mac, postwoman app, postwoman for windows, postwoman google chrome, postwoman chrome app, get postwoman, postwoman web, postwoman android, postwoman app for chrome, postwoman mobile app, postwoman web app, api, request, testing, tool, rest, websocket, sse, graphql, socketio",
+  app: {
+    background: "#202124",
+  },
+  social: {
+    twitter: "@hoppscotch_io",
+  },
+} as const
+
+export const META_TAGS = (env: Record<string, string>): IHTMLTag[] => [
+  {
+    name: "keywords",
+    content: APP_INFO.keywords,
+  },
+  {
+    name: "X-UA-Compatible",
+    content: "IE=edge, chrome=1",
+  },
+  {
+    name: "name",
+    content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`,
+  },
+  {
+    name: "description",
+    content: APP_INFO.description,
+  },
+  {
+    name: "image",
+    content: `${env.VITE_BASE_URL}/banner.png`,
+  },
+  // Open Graph tags
+  {
+    name: "og:title",
+    content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`,
+  },
+  {
+    name: "og:description",
+    content: APP_INFO.description,
+  },
+  {
+    name: "og:image",
+    content: `${env.VITE_BASE_URL}/banner.png`,
+  },
+  // Twitter tags
+  {
+    name: "twitter:card",
+    content: "summary_large_image",
+  },
+  {
+    name: "twitter:site",
+    content: APP_INFO.social.twitter,
+  },
+  {
+    name: "twitter:creator",
+    content: APP_INFO.social.twitter,
+  },
+  {
+    name: "twitter:title",
+    content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`,
+  },
+  {
+    name: "twitter:description",
+    content: APP_INFO.description,
+  },
+  {
+    name: "twitter:image",
+    content: `${env.VITE_BASE_URL}/banner.png`,
+  },
+  // Add to homescreen for Chrome on Android. Fallback for PWA (handled by nuxt)
+  {
+    name: "application-name",
+    content: APP_INFO.name,
+  },
+  // Windows phone tile icon
+  {
+    name: "msapplication-TileImage",
+    content: `${env.VITE_BASE_URL}/icon.png`,
+  },
+  {
+    name: "msapplication-TileColor",
+    content: APP_INFO.app.background,
+  },
+  {
+    name: "msapplication-tap-highlight",
+    content: "no",
+  },
+  // iOS Safari
+  {
+    name: "apple-mobile-web-app-title",
+    content: APP_INFO.name,
+  },
+  {
+    name: "apple-mobile-web-app-capable",
+    content: "yes",
+  },
+  {
+    name: "apple-mobile-web-app-status-bar-style",
+    content: "black-translucent",
+  },
+  // PWA
+  {
+    name: "theme-color",
+    content: APP_INFO.app.background,
+  },
+  {
+    name: "mask-icon",
+    content: "/icon.png",
+    color: APP_INFO.app.background,
+  },
+]

+ 61 - 0
packages/hoppscotch-selfhost-web/package.json

@@ -0,0 +1,61 @@
+{
+  "name": "@hoppscotch/selfhost-web",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "node --max_old_space_size=16384 ./node_modules/vite/bin/vite.js build",
+    "preview": "vite preview",
+    "lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
+    "lint:ts": "vue-tsc --noEmit",
+    "lintfix": "eslint --fix src --ext .ts,.js,.vue --ignore-path .gitignore .",
+    "prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint",
+    "generate": "pnpm run build",
+    "do-dev": "pnpm run dev",
+    "do-build-prod": "pnpm run build",
+    "do-lint": "pnpm run prod-lint",
+    "do-typecheck": "pnpm run lint",
+    "do-lintfix": "pnpm run lintfix"
+  },
+  "dependencies": {
+    "@hoppscotch/common": "workspace:^",
+    "axios": "^0.21.4",
+    "buffer": "^6.0.3",
+    "firebase": "^9.8.4",
+    "process": "^0.11.10",
+    "rxjs": "^7.5.5",
+    "stream-browserify": "^3.0.0",
+    "util": "^0.12.4",
+    "vue": "^3.2.41",
+    "workbox-window": "^6.5.4"
+  },
+  "devDependencies": {
+    "@intlify/vite-plugin-vue-i18n": "^6.0.1",
+    "@rushstack/eslint-patch": "^1.1.4",
+    "@typescript-eslint/eslint-plugin": "^5.19.0",
+    "@typescript-eslint/parser": "^5.19.0",
+    "@vitejs/plugin-legacy": "^2.3.0",
+    "@vitejs/plugin-vue": "^3.2.0",
+    "@vue/eslint-config-typescript": "^11.0.1",
+    "cross-env": "^7.0.3",
+    "eslint": "^8.28.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.5.1",
+    "typescript": "^4.6.4",
+    "unplugin-icons": "^0.14.9",
+    "unplugin-vue-components": "^0.21.0",
+    "vite": "^3.2.3",
+    "vite-plugin-fonts": "^0.6.0",
+    "vite-plugin-html-config": "^1.0.10",
+    "vite-plugin-inspect": "^0.7.4",
+    "vite-plugin-pages": "^0.26.0",
+    "vite-plugin-pages-sitemap": "^1.4.0",
+    "vite-plugin-pwa": "^0.13.1",
+    "vite-plugin-static-copy": "^0.12.0",
+    "vite-plugin-vue-layouts": "^0.7.0",
+    "vite-plugin-windicss": "^1.8.8",
+    "vue-tsc": "^1.0.9",
+    "windicss": "^3.5.6"
+  }
+}

+ 6 - 0
packages/hoppscotch-selfhost-web/src/main.ts

@@ -0,0 +1,6 @@
+import { createHoppApp } from "@hoppscotch/common"
+import { def as authDef } from "./platform/auth"
+
+createHoppApp("#app", {
+  auth: authDef,
+})

+ 330 - 0
packages/hoppscotch-selfhost-web/src/platform/auth.ts

@@ -0,0 +1,330 @@
+import axios from "axios"
+import {
+  AuthEvent,
+  AuthPlatformDef,
+  HoppUser,
+} from "@hoppscotch/common/platform/auth"
+import { BehaviorSubject, Subject } from "rxjs"
+import {
+  getLocalConfig,
+  removeLocalConfig,
+  setLocalConfig,
+} from "@hoppscotch/common/newstore/localpersistence"
+import { Ref, ref, watch } from "vue"
+
+export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
+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,
+  })
+}
+
+async function signInUserWithGithubFB() {
+  window.location.href = `${import.meta.env.VITE_BACKEND_API_URL}/auth/github`
+}
+
+async function signInUserWithGoogleFB() {
+  window.location.href = `${import.meta.env.VITE_BACKEND_API_URL}/auth/google`
+}
+
+async function signInUserWithMicrosoftFB() {
+  window.location.href = `${
+    import.meta.env.VITE_BACKEND_API_URL
+  }/auth/microsoft`
+}
+
+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
+}
+
+const isGettingInitialUser: Ref<null | boolean> = ref(null)
+
+function setUser(user: HoppUser | null) {
+  currentUser$.next(user)
+  probableUser$.next(user)
+
+  setLocalConfig("login_state", JSON.stringify(user))
+}
+
+async function setInitialUser() {
+  isGettingInitialUser.value = true
+  const res = await getInitialUserDetails()
+
+  const error = res.errors && res.errors[0]
+
+  // 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)
+      isGettingInitialUser.value = false
+    }
+
+    return
+  }
+
+  // no errors, we have a valid user
+  if (res.data && res.data.me) {
+    const hoppBackendUser = res.data.me
+
+    const hoppUser: HoppUser = {
+      uid: hoppBackendUser.uid,
+      displayName: hoppBackendUser.displayName,
+      email: hoppBackendUser.email,
+      photoURL: hoppBackendUser.photoURL,
+      // all our signin methods currently guarantees the email is verified
+      emailVerified: true,
+    }
+
+    setUser(hoppUser)
+
+    isGettingInitialUser.value = false
+
+    authEvents$.next({
+      event: "login",
+      user: hoppUser,
+    })
+
+    return
+  }
+}
+
+async function refreshToken() {
+  const res = await axios.get(
+    `${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`,
+    {
+      withCredentials: true,
+    }
+  )
+
+  const isSuccessful = res.status === 200
+
+  if (isSuccessful) {
+    authEvents$.next({
+      event: "token_refresh",
+    })
+  }
+
+  return isSuccessful
+}
+
+async function sendMagicLink(email: string) {
+  const res = await axios.post(
+    `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin`,
+    {
+      email,
+    },
+    {
+      withCredentials: true,
+    }
+  )
+
+  if (res.data && res.data.deviceIdentifier) {
+    setLocalConfig("deviceIdentifier", res.data.deviceIdentifier)
+  } else {
+    throw new Error("test: does not get device identifier")
+  }
+
+  return res.data
+}
+
+export const def: AuthPlatformDef = {
+  getCurrentUserStream: () => currentUser$,
+  getAuthEventsStream: () => authEvents$,
+  getProbableUserStream: () => probableUser$,
+
+  getCurrentUser: () => currentUser$.value,
+  getProbableUser: () => probableUser$.value,
+
+  getBackendHeaders() {
+    return {}
+  },
+  getGQLClientOptions() {
+    return {
+      fetchOptions: {
+        credentials: "include",
+      },
+    }
+  },
+
+  /**
+   * 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) {
+    authEvents$.subscribe((event) => {
+      if (
+        event.event == "login" ||
+        event.event == "logout" ||
+        event.event == "token_refresh"
+      ) {
+        func()
+      }
+    })
+  },
+
+  /**
+   * 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 searchParams = new URLSearchParams(urlObject.search)
+
+    return !!searchParams.get("token")
+  },
+
+  async verifyEmailAddress() {
+    return
+  },
+  async signInUserWithGoogle() {
+    await signInUserWithGoogleFB()
+  },
+  async signInUserWithGithub() {
+    await signInUserWithGithubFB()
+    return undefined
+  },
+  async signInUserWithMicrosoft() {
+    await signInUserWithMicrosoftFB()
+  },
+  async signInWithEmailLink(email: string, url: string) {
+    const urlObject = new URL(url)
+    const searchParams = new URLSearchParams(urlObject.search)
+
+    const token = searchParams.get("token")
+    const deviceIdentifier = getLocalConfig("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
+  },
+
+  async signOutUser() {
+    // if (!currentUser$.value) throw new Error("No user has logged in")
+
+    await logout()
+
+    probableUser$.next(null)
+    currentUser$.next(null)
+    removeLocalConfig("login_state")
+
+    authEvents$.next({
+      event: "logout",
+    })
+  },
+
+  async processMagicLink() {
+    if (this.isSignInWithEmailLink(window.location.href)) {
+      const deviceIdentifier = getLocalConfig("deviceIdentifier")
+
+      if (!deviceIdentifier) {
+        throw new Error(
+          "Device Identifier not found, you can only signin from the browser you generated the magic link"
+        )
+      }
+
+      await this.signInWithEmailLink(deviceIdentifier, window.location.href)
+
+      removeLocalConfig("deviceIdentifier")
+      window.location.href = "/"
+    }
+  },
+}

+ 7 - 0
packages/hoppscotch-selfhost-web/src/vite-env.d.ts

@@ -0,0 +1,7 @@
+/// <reference types="vite/client" />
+
+declare module "*.vue" {
+  import type { DefineComponent } from "vue"
+  const component: DefineComponent<{}, {}, any>
+  export default component
+}

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