feat: selfhost auth frontend (#15)

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Akash K
2023-02-09 01:12:44 +05:30
committed by GitHub
parent 3cf3feb2ae
commit 757d1add5b
14 changed files with 6468 additions and 4368 deletions

View File

@@ -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

View File

@@ -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",
},
],
},
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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,
},
]

View File

@@ -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"
}
}

View File

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

View File

@@ -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 = "/"
}
},
}

View File

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

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@hoppscotch/common": [ "../hoppscotch-common/src/index.ts" ],
"@hoppscotch/common/*": [ "../hoppscotch-common/src/*" ]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "meta.ts"]
}

View File

@@ -0,0 +1,193 @@
import { defineConfig, loadEnv, normalizePath } from "vite"
import { APP_INFO, META_TAGS } from "./meta"
import { viteStaticCopy as StaticCopy } from "vite-plugin-static-copy"
import generateSitemap from "vite-plugin-pages-sitemap"
import HtmlConfig from "vite-plugin-html-config"
import Vue from "@vitejs/plugin-vue"
import VueI18n from "@intlify/vite-plugin-vue-i18n"
import Components from "unplugin-vue-components/vite"
import Icons from "unplugin-icons/vite"
import Inspect from "vite-plugin-inspect"
import WindiCSS from "vite-plugin-windicss"
import { VitePWA } from "vite-plugin-pwa"
import Pages from "vite-plugin-pages"
import Layouts from "vite-plugin-vue-layouts"
import IconResolver from "unplugin-icons/resolver"
import { FileSystemIconLoader } from "unplugin-icons/loaders"
import * as path from "path"
import { VitePluginFonts } from "vite-plugin-fonts"
import legacy from "@vitejs/plugin-legacy"
const ENV = loadEnv("development", path.resolve(__dirname, "../../"))
export default defineConfig({
envDir: path.resolve(__dirname, "../../"),
// TODO: Migrate @hoppscotch/data to full ESM
define: {
// For 'util' polyfill required by dep of '@apidevtools/swagger-parser'
"process.env": {},
},
server: {
port: 3000,
},
preview: {
port: 3000,
},
publicDir: path.resolve(__dirname, "../hoppscotch-common/public"),
build: {
sourcemap: true,
emptyOutDir: true,
},
resolve: {
alias: {
// TODO: Maybe leave ~ only for individual apps and not use on common
"~": path.resolve(__dirname, "../hoppscotch-common/src"),
"@hoppscotch/common": "@hoppscotch/common/src",
"@composables": path.resolve(
__dirname,
"../hoppscotch-common/src/composables"
),
"@modules": path.resolve(__dirname, "../hoppscotch-common/src/modules"),
"@components": path.resolve(
__dirname,
"../hoppscotch-common/src/components"
),
"@helpers": path.resolve(__dirname, "../hoppscotch-common/src/helpers"),
"@functional": path.resolve(
__dirname,
"../hoppscotch-common/src/helpers/functional"
),
"@workers": path.resolve(__dirname, "../hoppscotch-common/src/workers"),
stream: "stream-browserify",
util: "util",
},
dedupe: ["vue"],
},
plugins: [
Inspect(), // go to url -> /__inspect
HtmlConfig({
metas: META_TAGS(ENV),
}),
Vue(),
Pages({
routeStyle: "nuxt",
dirs: "../hoppscotch-common/src/pages",
importMode: "async",
onRoutesGenerated(routes) {
// HACK: See: https://github.com/jbaubree/vite-plugin-pages-sitemap/issues/173
return ((generateSitemap as any).default as typeof generateSitemap)({
routes,
nuxtStyle: true,
allowRobots: true,
dest: ".sitemap-gen",
hostname: ENV.VITE_BASE_URL,
})
},
}),
StaticCopy({
targets: [
{
src: normalizePath(path.resolve(__dirname, "./.sitemap-gen/*")),
dest: normalizePath(path.resolve(__dirname, "./dist")),
},
],
}),
Layouts({
layoutsDirs: "../hoppscotch-common/src/layouts",
defaultLayout: "default",
}),
VueI18n({
runtimeOnly: false,
compositionOnly: true,
include: [path.resolve(__dirname, "locales")],
}),
WindiCSS({
root: path.resolve(__dirname, "../hoppscotch-common"),
}),
Components({
dts: "../hoppscotch-common/src/components.d.ts",
dirs: [
"../hoppscotch-common/src/components",
"../hoppscotch-ui/src/components",
],
directoryAsNamespace: true,
resolvers: [
IconResolver({
prefix: "icon",
customCollections: ["hopp", "auth", "brands"],
}),
],
types: [
{
from: "vue-tippy",
names: ["Tippy"],
},
],
}),
Icons({
compiler: "vue3",
customCollections: {
hopp: FileSystemIconLoader("../hoppscotch-common/assets/icons"),
auth: FileSystemIconLoader("../hoppscotch-common/assets/icons/auth"),
brands: FileSystemIconLoader(
"../hoppscotch-common/assets/icons/brands"
),
},
}),
VitePWA({
manifest: {
name: APP_INFO.name,
short_name: APP_INFO.name,
description: APP_INFO.shortDescription,
start_url: "/?source=pwa",
background_color: APP_INFO.app.background,
theme_color: APP_INFO.app.background,
icons: [
{
src: "/icon.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable",
},
{
src: "/logo.svg",
sizes: "48x48 72x72 96x96 128x128 256x256 512x512",
type: "image/svg+xml",
purpose: "any maskable",
},
],
},
registerType: "prompt",
workbox: {
cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 4194304,
navigateFallbackDenylist: [
/robots.txt/,
/sitemap.xml/,
/discord/,
/telegram/,
/beta/,
/careers/,
/newsletter/,
/twitter/,
/github/,
/announcements/,
],
},
}),
VitePluginFonts({
google: {
families: [
"Inter:wght@400;500;600;700;800",
"Roboto+Mono:wght@400;500",
"Material+Icons",
],
},
}),
legacy({
modernPolyfills: ["es.string.replace-all"],
renderLegacyChunks: false,
}),
],
})