Compare commits

...

4 Commits

Author SHA1 Message Date
Liyas Thomas
20c8973f5d chore: set X-Frame-Options to SAMEORIGIN 2023-02-01 23:46:15 +05:30
Liyas Thomas
461d67ce90 feat: deploy hoppscotch-ui 2023-02-01 23:15:50 +05:30
Liyas Thomas
492c3a0902 fix: open gist html_url after export 2023-02-01 20:59:12 +05:30
Akash K
d5d516ce18 chore: abstract auth from hoppscotch/commons to hoppscotch/web (#2899) 2023-02-01 20:47:22 +05:30
47 changed files with 1073 additions and 823 deletions

41
.github/workflows/deploy-netlify-ui.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Deploy to Netlify (ui)
on:
push:
branches: [main]
# run this workflow only if an update is made to the ui package
paths:
- "packages/hoppscotch-ui/**"
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: mv .env.example .env
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7
run_install: true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Build site
run: pnpm run generate-ui
# Deploy the ui site with netlify-cli
- name: Deploy to Netlify (ui)
run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View File

@@ -10,7 +10,7 @@
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Frame-Options = "SAMEORIGIN"
X-XSS-Protection = "1; mode=block"
[[redirects]]

View File

@@ -15,7 +15,8 @@
"typecheck": "pnpm -r do-typecheck",
"lintfix": "pnpm -r do-lintfix",
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
"test": "pnpm -r do-test"
"test": "pnpm -r do-test",
"generate-ui": "pnpm -r do-build-ui"
},
"workspaces": [
"./packages/*"

View File

@@ -29,10 +29,7 @@ import IconCheck from "~icons/lucide/check"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream"
import { authIdToken$ } from "~/helpers/fb/auth"
const userAuthToken = useReadonlyStream(authIdToken$, null)
import { platform } from "~/platform"
const t = useI18n()
@@ -53,8 +50,9 @@ const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
// Copy user auth token to clipboard
const copyUserAuthToken = () => {
if (userAuthToken.value) {
copyToClipboard(userAuthToken.value)
const token = platform.auth.getDevOptsBackendIDToken()
if (token) {
copyToClipboard(token)
copyIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
} else {

View File

@@ -219,7 +219,7 @@ import { showChat } from "@modules/crisp"
import { useSetting } from "@composables/settings"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { TippyComponent } from "vue-tippy"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { invokeAction } from "@helpers/actions"
@@ -236,7 +236,10 @@ const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
const navigatorShare = !!navigator.share
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
watch(
() => ZEN_MODE.value,

View File

@@ -171,11 +171,10 @@ import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUserPlus from "~icons/lucide/user-plus"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { probableUser$ } from "@helpers/fb/auth"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { invokeAction } from "@helpers/actions"
import { platform } from "~/index"
const t = useI18n()
@@ -194,7 +193,10 @@ const mdAndLarger = breakpoints.greater("md")
const network = reactive(useNetwork())
const currentUser = useReadonlyStream(probableUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
// Template refs
const tippyActions = ref<any | null>(null)

View File

@@ -172,7 +172,7 @@ import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
import { StepReturnValue } from "~/helpers/import-export/steps"
@@ -263,7 +263,10 @@ watch(inputChooseGistToImportFrom, (url) => {
})
const myCollections = useReadonlyStream(restCollections$, [])
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const importerAction = async (stepResults: StepReturnValue[]) => {
if (!importerModule.value) return

View File

@@ -99,7 +99,7 @@ import IconFolderPlus from "~icons/lucide/folder-plus"
import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, ref } from "vue"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
@@ -120,7 +120,10 @@ const emit = defineEmits<{
const toast = useToast()
const t = useI18n()
const collections = useReadonlyStream(graphqlCollections$, [])
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
// Template refs
const tippyActions = ref<any | null>(null)

View File

@@ -241,7 +241,7 @@ import {
} from "~/helpers/backend/helpers"
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
import * as E from "fp-ts/Either"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { createCollectionGists } from "~/helpers/gist"
import { invokeAction } from "~/helpers/actions"
@@ -318,7 +318,10 @@ const confirmModalTitle = ref<string | null>(null)
const filterTexts = ref("")
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const myCollections = useReadonlyStream(restCollections$, [], "deep")
// Export - Import refs
@@ -1462,7 +1465,7 @@ const createCollectionGist = async () => {
(result) => {
toast.success(t("export.gist_created").toString())
creatingGistCollection.value = false
window.open(result.data.url)
window.open(result.data.html_url)
}
)
)()

View File

@@ -78,7 +78,7 @@
import { nextTick, ref, watch } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { onLoggedIn } from "@composables/auth"
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
import { platform } from "~/platform"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useReadonlyStream } from "@composables/stream"
import { useLocalState } from "~/newstore/localstate"
@@ -111,7 +111,10 @@ const emit = defineEmits<{
(e: "update-selected-team", team: SelectedTeam): void
}>()
const currentUser = useReadonlyStream(currentUserInfo$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const adapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(adapter.teamList$, null)
@@ -138,7 +141,9 @@ watch(
)
onLoggedIn(() => {
adapter.initialize()
try {
adapter.initialize()
} catch (e) {}
})
const onTeamSelectIntersect = () => {

View File

@@ -107,7 +107,7 @@ import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, ref } from "vue"
import { Environment } from "@hoppscotch/data"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import axios from "axios"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
@@ -141,7 +141,10 @@ const t = useI18n()
const loading = ref(false)
const myEnvironments = useReadonlyStream(environments$, [])
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
// Template refs
const tippyActions = ref<TippyComponent | null>(null)
@@ -187,7 +190,7 @@ const createEnvironmentGist = async () => {
)
toast.success(t("export.gist_created").toString())
window.open(res.html_url)
window.open(res.data.html_url)
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
console.error(e)

View File

@@ -183,7 +183,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { isEqual } from "lodash-es"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { Team } from "~/helpers/backend/graphql"
import { useReadonlyStream, useStream } from "@composables/stream"
import { useI18n } from "~/composables/i18n"
@@ -222,7 +222,10 @@ const globalEnvironment = computed(() => ({
variables: globalEnv.value,
}))
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const updateSelectedTeam = (newSelectedTeam: SelectedTeam) => {
environmentType.value.selectedTeam = newSelectedTeam

View File

@@ -122,16 +122,7 @@
<script lang="ts">
import { defineComponent } from "vue"
import {
signInUserWithGoogle,
signInUserWithGithub,
signInUserWithMicrosoft,
setProviderInfo,
currentUser$,
signInWithEmail,
linkWithFBCredentialFromAuthError,
getGithubCredentialFromResult,
} from "~/helpers/fb/auth"
import { platform } from "~/platform"
import IconGithub from "~icons/auth/github"
import IconGoogle from "~icons/auth/google"
import IconEmail from "~icons/auth/email"
@@ -174,6 +165,8 @@ export default defineComponent({
}
},
mounted() {
const currentUser$ = platform.auth.getCurrentUserStream()
this.subscribeToStream(currentUser$, (user) => {
if (user) this.hideModal()
})
@@ -186,8 +179,7 @@ export default defineComponent({
this.signingInWithGoogle = true
try {
await signInUserWithGoogle()
this.showLoginSuccess()
await platform.auth.signInUserWithGoogle()
} catch (e) {
console.error(e)
/*
@@ -202,35 +194,32 @@ export default defineComponent({
async signInWithGithub() {
this.signingInWithGitHub = true
try {
const result = await signInUserWithGithub()
const credential = getGithubCredentialFromResult(result)!
const token = credential.accessToken
setProviderInfo(result.providerId!, token!)
const result = await platform.auth.signInUserWithGithub()
this.showLoginSuccess()
} catch (e) {
console.error(e)
// This user's email is already present in Firebase but with other providers, namely Google or Microsoft
if (
(e as any).code === "auth/account-exists-with-different-credential"
) {
this.toast.info(`${this.t("auth.account_exists")}`, {
duration: 0,
closeOnSwipe: false,
action: {
text: `${this.t("action.yes")}`,
onClick: async (_, toastObject) => {
await linkWithFBCredentialFromAuthError(e)
this.showLoginSuccess()
if (!result) {
this.signingInWithGitHub = false
return
}
toastObject.goAway(0)
},
if (result.type === "success") {
// this.showLoginSuccess()
} else if (result.type === "account-exists-with-different-cred") {
this.toast.info(`${this.t("auth.account_exists")}`, {
duration: 0,
closeOnSwipe: false,
action: {
text: `${this.t("action.yes")}`,
onClick: async (_, toastObject) => {
await result.link()
this.showLoginSuccess()
toastObject.goAway(0)
},
})
} else {
this.toast.error(`${this.t("error.something_went_wrong")}`)
}
},
})
} else {
console.log("error logging into github", result.err)
this.toast.error(`${this.t("error.something_went_wrong")}`)
}
this.signingInWithGitHub = false
@@ -239,8 +228,8 @@ export default defineComponent({
this.signingInWithMicrosoft = true
try {
await signInUserWithMicrosoft()
this.showLoginSuccess()
await platform.auth.signInUserWithMicrosoft()
// this.showLoginSuccess()
} catch (e) {
console.error(e)
/*
@@ -259,11 +248,8 @@ export default defineComponent({
async signInWithEmail() {
this.signingInWithEmail = true
const actionCodeSettings = {
url: `${import.meta.env.VITE_BASE_URL}/enter`,
handleCodeInApp: true,
}
await signInWithEmail(this.form.email, actionCodeSettings)
await platform.auth
.signInWithEmail(this.form.email)
.then(() => {
this.mode = "email-sent"
setLocalConfig("emailForSignIn", this.form.email)

View File

@@ -22,7 +22,7 @@ import { ref } from "vue"
import IconLogOut from "~icons/lucide/log-out"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { signOutUser } from "~/helpers/fb/auth"
import { platform } from "~/platform"
defineProps({
outline: {
@@ -47,7 +47,7 @@ const t = useI18n()
const logout = async () => {
try {
await signOutUser()
await platform.auth.signOutUser()
toast.success(`${t("auth.logged_out")}`)
} catch (e) {
console.error(e)

View File

@@ -82,7 +82,7 @@ import { ref, watchEffect, computed } from "vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { GQLError } from "~/helpers/backend/GQLClient"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { onAuthEvent, onLoggedIn } from "@composables/auth"
import { useReadonlyStream } from "@composables/stream"
@@ -102,7 +102,10 @@ usePageHead({
title: computed(() => t("navigation.profile")),
})
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const displayName = ref(currentUser.value?.displayName)
watchEffect(() => (displayName.value = currentUser.value?.displayName))
@@ -121,7 +124,9 @@ const loading = computed(
)
onLoggedIn(() => {
adapter.initialize()
try {
adapter.initialize()
} catch (e) {}
})
onAuthEvent((ev) => {

View File

@@ -109,7 +109,7 @@ import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { GetMyTeamsDocument, GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { deleteUser } from "~/helpers/backend/mutations/Profile"
import { signOutUser } from "~/helpers/fb/auth"
import { platform } from "~/platform"
const t = useI18n()
const toast = useToast()
@@ -162,7 +162,7 @@ const deleteUserAccount = async () => {
deletingUser.value = false
showDeleteAccountModal.value = false
toast.success(t("settings.account_deleted"))
signOutUser()
platform.auth.signOutUser()
router.push(`/`)
}
)

View File

@@ -108,7 +108,9 @@ const loading = computed(
)
onLoggedIn(() => {
adapter.initialize()
try {
adapter.initialize()
} catch (e) {}
})
const displayModalAdd = (shouldDisplay: boolean) => {

View File

@@ -1,18 +1,8 @@
import {
currentUser$,
HoppUser,
AuthEvent,
authEvents$,
authIdToken$,
} from "@helpers/fb/auth"
import {
map,
distinctUntilChanged,
filter,
Subscription,
combineLatestWith,
} from "rxjs"
import { onBeforeUnmount, onMounted } from "vue"
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
@@ -21,26 +11,25 @@ import { onBeforeUnmount, onMounted } from "vue"
* was already resolved before mount.
*/
export function onLoggedIn(exec: (user: HoppUser) => void) {
let sub: Subscription | null = null
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
let watchStop: WatchStopHandle | null = null
onMounted(() => {
sub = currentUser$
.pipe(
// We don't consider the state as logged in unless we also have an id token
combineLatestWith(authIdToken$),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
filter(([_, token]) => !!token),
map((user) => !!user), // Get a logged in status (true or false)
distinctUntilChanged(), // Don't propagate unless the status updates
filter((x) => x) // Don't propagate unless it is logged in
)
.subscribe(() => {
exec(currentUser$.value!)
})
if (currentUser.value) exec(currentUser.value)
watchStop = watch(currentUser, (newVal, prev) => {
if (prev === null && newVal !== null) {
exec(newVal)
}
})
})
onBeforeUnmount(() => {
sub?.unsubscribe()
watchStop?.()
})
}
@@ -57,6 +46,8 @@ export function onLoggedIn(exec: (user: HoppUser) => void) {
* @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(() => {

View File

@@ -12,6 +12,7 @@ import {
CombinedError,
Operation,
OperationResult,
Client,
} from "@urql/core"
import { authExchange } from "@urql/exchange-auth"
import { devtoolsExchange } from "@urql/devtools"
@@ -21,12 +22,7 @@ import * as TE from "fp-ts/TaskEither"
import { pipe, constVoid, flow } from "fp-ts/function"
import { subscribe, pipe as wonkaPipe } from "wonka"
import { filter, map, Subject } from "rxjs"
import {
authIdToken$,
getAuthIDToken,
probableUser$,
waitProbableLoginToConfirm,
} from "~/helpers/fb/auth"
import { platform } from "~/platform"
// TODO: Implement caching
@@ -57,11 +53,7 @@ export const gqlClientError$ = new Subject<GQLClientErrorEvent>()
const createSubscriptionClient = () => {
return new SubscriptionClient(BACKEND_WS_URL, {
reconnect: true,
connectionParams: () => {
return {
authorization: `Bearer ${authIdToken$.value}`,
}
},
connectionParams: () => platform.auth.getBackendHeaders(),
connectionCallback(error) {
if (error?.length > 0) {
gqlClientError$.next({
@@ -79,7 +71,7 @@ const createHoppClient = () => {
dedupExchange,
authExchange({
addAuthToOperation({ authState, operation }) {
if (!authState || !authState.authToken) {
if (!authState) {
return operation
}
@@ -88,28 +80,29 @@ const createHoppClient = () => {
? operation.context.fetchOptions()
: operation.context.fetchOptions || {}
const authHeaders = platform.auth.getBackendHeaders()
return makeOperation(operation.kind, operation, {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
Authorization: `Bearer ${authState.authToken}`,
...authHeaders,
},
},
})
},
willAuthError({ authState }) {
return !authState || !authState.authToken
willAuthError() {
return platform.auth.willBackendHaveAuthError()
},
getAuth: async () => {
if (!probableUser$.value) return { authToken: null }
const probableUser = platform.auth.getProbableUser()
await waitProbableLoginToConfirm()
if (probableUser !== null)
await platform.auth.waitProbableLoginToConfirm()
return {
authToken: getAuthIDToken(),
}
return {}
},
}),
fetchExchange,
@@ -137,31 +130,40 @@ const createHoppClient = () => {
return createClient({
url: BACKEND_GQL_URL,
exchanges,
...(platform.auth.getGQLClientOptions
? platform.auth.getGQLClientOptions()
: {}),
})
}
let subscriptionClient: SubscriptionClient | null
export const client = ref(createHoppClient())
authIdToken$.subscribe((idToken) => {
// triggering reconnect by closing the websocket client
if (idToken && subscriptionClient) {
subscriptionClient?.client?.close()
}
// creating new subscription
if (idToken && !subscriptionClient) {
subscriptionClient = createSubscriptionClient()
}
// closing existing subscription client.
if (!idToken && subscriptionClient) {
subscriptionClient.close()
subscriptionClient = null
}
export const client = ref<Client>()
export function initBackendGQLClient() {
client.value = createHoppClient()
})
platform.auth.onBackendGQLClientShouldReconnect(() => {
const currentUser = platform.auth.getCurrentUser()
// triggering reconnect by closing the websocket client
if (currentUser && subscriptionClient) {
subscriptionClient?.client?.close()
}
// creating new subscription
if (currentUser && !subscriptionClient) {
subscriptionClient = createSubscriptionClient()
}
// closing existing subscription client.
if (!currentUser && subscriptionClient) {
subscriptionClient.close()
subscriptionClient = null
}
client.value = createHoppClient()
})
}
type RunQueryOptions<T = any, V = object> = {
query: TypedDocumentNode<T, V>
@@ -185,7 +187,7 @@ export const runGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
args: RunQueryOptions<DocType, DocVarType>
): Promise<E.Either<GQLError<DocErrorType>, DocType>> => {
const request = createRequest<DocType, DocVarType>(args.query, args.variables)
const source = client.value.executeQuery(request, {
const source = client.value!.executeQuery(request, {
requestPolicy: "network-only",
})
@@ -250,7 +252,7 @@ export const runGQLSubscription = <
) => {
const result$ = new Subject<E.Either<GQLError<DocErrorType>, DocType>>()
const source = client.value.executeSubscription(
const source = client.value!.executeSubscription(
createRequest(args.query, args.variables)
)
@@ -342,8 +344,8 @@ export const runMutation = <
pipe(
TE.tryCatch(
() =>
client.value
.mutation(mutation, variables, {
client
.value!.mutation(mutation, variables, {
requestPolicy: "cache-and-network",
...additionalConfig,
})

View File

@@ -6,7 +6,7 @@ import {
setUserId,
setUserProperties,
} from "firebase/analytics"
import { authEvents$ } from "./auth"
import { platform } from "~/platform"
import {
HoppAccentColor,
HoppBgColor,
@@ -42,13 +42,15 @@ export function initAnalytics() {
}
function initLoginListeners() {
const authEvents$ = platform.auth.getAuthEventsStream()
authEvents$.subscribe((ev) => {
if (ev.event === "login") {
if (settingsStore.value.TELEMETRY_ENABLED && analytics) {
setUserId(analytics, ev.user.uid)
logEvent(analytics, "login", {
method: ev.user.providerData[0]?.providerId, // Assume the first provider is the login provider
method: ev.user.provider, // Assume the first provider is the login provider
})
}
} else if (ev.event === "logout") {

View File

@@ -1,434 +0,0 @@
import {
User,
getAuth,
onAuthStateChanged,
onIdTokenChanged,
signInWithPopup,
GoogleAuthProvider,
GithubAuthProvider,
OAuthProvider,
signInWithEmailAndPassword as signInWithEmailAndPass,
isSignInWithEmailLink as isSignInWithEmailLinkFB,
fetchSignInMethodsForEmail,
sendSignInLinkToEmail,
signInWithEmailLink as signInWithEmailLinkFB,
ActionCodeSettings,
signOut,
linkWithCredential,
AuthCredential,
AuthError,
UserCredential,
updateProfile,
updateEmail,
sendEmailVerification,
reauthenticateWithCredential,
} from "firebase/auth"
import {
onSnapshot,
getFirestore,
setDoc,
doc,
updateDoc,
} from "firebase/firestore"
import { BehaviorSubject, filter, Subject, Subscription } from "rxjs"
import {
setLocalConfig,
getLocalConfig,
removeLocalConfig,
} from "~/newstore/localpersistence"
export type HoppUser = User & {
provider?: string
accessToken?: string
}
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: "logout" } // No authentication and we have no previous state
| { event: "authTokenUpdate"; user: HoppUser; newToken: string | null } // Token has been updated
/**
* A BehaviorSubject emitting the currently logged in user (or null if not logged in)
*/
export const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
/**
* A BehaviorSubject emitting the current idToken
*/
export const authIdToken$ = new BehaviorSubject<string | null>(null)
/**
* A subject that emits events related to authentication flows
*/
export const authEvents$ = new Subject<AuthEvent>()
/**
* Like currentUser$ but also gives probable user value
*/
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
/**
* Resolves when the probable login resolves into proper login
*/
export const waitProbableLoginToConfirm = () =>
new Promise<void>((resolve, reject) => {
if (authIdToken$.value) resolve()
if (!probableUser$.value) reject(new Error("no_probable_user"))
let sub: Subscription | null = null
sub = authIdToken$.pipe(filter((token) => !!token)).subscribe(() => {
sub?.unsubscribe()
resolve()
})
})
/**
* Initializes the firebase authentication related subjects
*/
export function initAuth() {
const auth = getAuth()
const firestore = getFirestore()
let extraSnapshotStop: (() => void) | null = null
probableUser$.next(JSON.parse(getLocalConfig("login_state") ?? "null"))
onAuthStateChanged(auth, (user) => {
/** Whether the user was logged in before */
const wasLoggedIn = currentUser$.value !== null
if (user) {
probableUser$.next(user)
} else {
probableUser$.next(null)
removeLocalConfig("login_state")
}
if (!user && extraSnapshotStop) {
extraSnapshotStop()
extraSnapshotStop = null
} else if (user) {
// Merge all the user info from all the authenticated providers
user.providerData.forEach((profile) => {
if (!profile) return
const us = {
updatedOn: new Date(),
provider: profile.providerId,
name: profile.displayName,
email: profile.email,
photoUrl: profile.photoURL,
uid: profile.uid,
}
setDoc(doc(firestore, "users", user.uid), us, { merge: true }).catch(
(e) => console.error("error updating", us, e)
)
})
extraSnapshotStop = onSnapshot(
doc(firestore, "users", user.uid),
(doc) => {
const data = doc.data()
const userUpdate: HoppUser = user
if (data) {
// Write extra provider data
userUpdate.provider = data.provider
userUpdate.accessToken = data.accessToken
}
currentUser$.next(userUpdate)
}
)
}
currentUser$.next(user)
// User wasn't found before, but now is there (login happened)
if (!wasLoggedIn && user) {
authEvents$.next({
event: "login",
user: currentUser$.value!,
})
} else if (wasLoggedIn && !user) {
// User was found before, but now is not there (logout happened)
authEvents$.next({
event: "logout",
})
}
})
onIdTokenChanged(auth, async (user) => {
if (user) {
authIdToken$.next(await user.getIdToken())
authEvents$.next({
event: "authTokenUpdate",
newToken: authIdToken$.value,
user: currentUser$.value!, // Force not-null because user is defined
})
setLocalConfig("login_state", JSON.stringify(user))
} else {
authIdToken$.next(null)
}
})
}
export function getAuthIDToken(): string | null {
return authIdToken$.getValue()
}
/**
* Sign user in with a popup using Google
*/
export async function signInUserWithGoogle() {
return await signInWithPopup(getAuth(), new GoogleAuthProvider())
}
/**
* Sign user in with a popup using Github
*/
export async function signInUserWithGithub() {
return await signInWithPopup(
getAuth(),
new GithubAuthProvider().addScope("gist")
)
}
/**
* Sign user in with a popup using Microsoft
*/
export async function signInUserWithMicrosoft() {
return await signInWithPopup(getAuth(), new OAuthProvider("microsoft.com"))
}
/**
* Sign user in with email and password
*/
export async function signInWithEmailAndPassword(
email: string,
password: string
) {
return await signInWithEmailAndPass(getAuth(), email, password)
}
/**
* Gets the sign in methods for a given email address
*
* @param email - Email to get the methods of
*
* @returns Promise for string array of the auth provider methods accessible
*/
export async function getSignInMethodsForEmail(email: string) {
return await fetchSignInMethodsForEmail(getAuth(), email)
}
export async function linkWithFBCredential(
user: User,
credential: AuthCredential
) {
return await linkWithCredential(user, credential)
}
/**
* Links account with another account given in a auth/account-exists-with-different-credential error
*
* @param error - Error caught after trying to login
*
* @returns Promise of UserCredential
*/
export async function linkWithFBCredentialFromAuthError(error: unknown) {
// credential is not null since this function is called after an auth/account-exists-with-different-credential error, ie credentials actually exist
const credentials = OAuthProvider.credentialFromError(error as AuthError)!
const otherLinkedProviders = (
await getSignInMethodsForEmail((error as AuthError).customData.email!)
).filter((providerId) => credentials.providerId !== providerId)
let user: User | null = null
if (otherLinkedProviders.indexOf("google.com") >= -1) {
user = (await signInUserWithGoogle()).user
} else if (otherLinkedProviders.indexOf("github.com") >= -1) {
user = (await signInUserWithGithub()).user
} else if (otherLinkedProviders.indexOf("microsoft.com") >= -1) {
user = (await signInUserWithMicrosoft()).user
}
// user is not null since going through each provider will return a user
return await linkWithCredential(user!, credentials)
}
/**
* Sends an email with the signin link to the user
*
* @param email - Email to send the email to
* @param actionCodeSettings - The settings to apply to the link
*/
export async function signInWithEmail(
email: string,
actionCodeSettings: ActionCodeSettings
) {
return await sendSignInLinkToEmail(getAuth(), email, actionCodeSettings)
}
/**
* Checks and returns whether the sign in link is an email link
*
* @param url - The URL to look in
*/
export function isSignInWithEmailLink(url: string) {
return isSignInWithEmailLinkFB(getAuth(), url)
}
/**
* Sends an email with sign in with email link
*
* @param email - Email to log in to
* @param url - The action URL which is used to validate login
*/
export async function signInWithEmailLink(email: string, url: string) {
return await signInWithEmailLinkFB(getAuth(), email, url)
}
/**
* Signs out the user
*/
export async function signOutUser() {
if (!currentUser$.value) throw new Error("No user has logged in")
await signOut(getAuth())
}
/**
* Sets the provider id and relevant provider auth token
* as user metadata
*
* @param id - The provider ID
* @param token - The relevant auth token for the given provider
*/
export async function setProviderInfo(id: string, token: string) {
if (!currentUser$.value) throw new Error("No user has logged in")
const us = {
updatedOn: new Date(),
provider: id,
accessToken: token,
}
try {
await updateDoc(
doc(getFirestore(), "users", currentUser$.value.uid),
us
).catch((e) => console.error("error updating", us, e))
} catch (e) {
console.error("error updating", e)
throw e
}
}
/**
* Sets the user's display name
*
* @param name - The new display name
*/
export async function setDisplayName(name: string) {
if (!currentUser$.value) throw new Error("No user has logged in")
const us = {
displayName: name,
}
try {
await updateProfile(currentUser$.value, us)
} catch (e) {
console.error("error updating", e)
throw e
}
}
/**
* Send user's email address verification mail
*/
export async function verifyEmailAddress() {
if (!currentUser$.value) throw new Error("No user has logged in")
try {
await sendEmailVerification(currentUser$.value)
} catch (e) {
console.error("error updating", e)
throw e
}
}
/**
* Sets the user's email address
*
* @param email - The new email address
*/
export async function setEmailAddress(email: string) {
if (!currentUser$.value) throw new Error("No user has logged in")
try {
await updateEmail(currentUser$.value, email)
} catch (e) {
await reauthenticateUser()
console.error("error updating", e)
throw e
}
}
/**
* Reauthenticate the user with the given credential
*/
async function reauthenticateUser() {
if (!currentUser$.value) throw new Error("No user has logged in")
const currentAuthMethod = currentUser$.value.provider
let credential
if (currentAuthMethod === "google.com") {
const result = await signInUserWithGithub()
credential = GithubAuthProvider.credentialFromResult(result)
} else if (currentAuthMethod === "github.com") {
const result = await signInUserWithGoogle()
credential = GoogleAuthProvider.credentialFromResult(result)
} else if (currentAuthMethod === "microsoft.com") {
const result = await signInUserWithMicrosoft()
credential = OAuthProvider.credentialFromResult(result)
} else if (currentAuthMethod === "password") {
const email = prompt(
"Reauthenticate your account using your current email:"
)
const actionCodeSettings = {
url: `${process.env.BASE_URL}/enter`,
handleCodeInApp: true,
}
await signInWithEmail(email as string, actionCodeSettings)
.then(() =>
alert(
`Check your inbox - we sent an email to ${email}. It contains a magic link that will reauthenticate your account.`
)
)
.catch((e) => {
alert(`Error: ${e.message}`)
console.error(e)
})
return
}
try {
await reauthenticateWithCredential(
currentUser$.value,
credential as AuthCredential
)
} catch (e) {
console.error("error updating", e)
throw e
}
}
export function getGithubCredentialFromResult(result: UserCredential) {
return GithubAuthProvider.credentialFromResult(result)
}

View File

@@ -9,7 +9,7 @@ import {
translateToNewRESTCollection,
translateToNewGQLCollection,
} from "@hoppscotch/data"
import { currentUser$ } from "./auth"
import { platform } from "~/platform"
import {
restCollections$,
graphqlCollections$,
@@ -44,20 +44,22 @@ export async function writeCollections(
collection: any[],
flag: CollectionFlags
) {
if (currentUser$.value === null)
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("User not logged in to write collections")
const cl = {
updatedOn: new Date(),
author: currentUser$.value.uid,
author_name: currentUser$.value.displayName,
author_image: currentUser$.value.photoURL,
author: currentUser.uid,
author_name: currentUser.displayName,
author_image: currentUser.photoURL,
collection,
}
try {
await setDoc(
doc(getFirestore(), "users", currentUser$.value.uid, flag, "sync"),
doc(getFirestore(), "users", currentUser.uid, flag, "sync"),
cl
)
} catch (e) {
@@ -67,10 +69,13 @@ export async function writeCollections(
}
export function initCollections() {
const currentUser$ = platform.auth.getCurrentUserStream()
const currentUser = platform.auth.getCurrentUser()
const restCollSub = restCollections$.subscribe((collections) => {
if (
loadedRESTCollections &&
currentUser$.value &&
currentUser &&
settingsStore.value.syncCollections
) {
writeCollections(collections, "collections")
@@ -80,7 +85,7 @@ export function initCollections() {
const gqlCollSub = graphqlCollections$.subscribe((collections) => {
if (
loadedGraphqlCollections &&
currentUser$.value &&
currentUser &&
settingsStore.value.syncCollections
) {
writeCollections(collections, "collectionsGraphql")

View File

@@ -6,7 +6,7 @@ import {
onSnapshot,
setDoc,
} from "firebase/firestore"
import { currentUser$ } from "./auth"
import { platform } from "~/platform"
import {
environments$,
globalEnv$,
@@ -32,26 +32,22 @@ let loadedEnvironments = false
let loadedGlobals = true
async function writeEnvironments(environment: Environment[]) {
if (currentUser$.value == null)
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("Cannot write environments when signed out")
const ev = {
updatedOn: new Date(),
author: currentUser$.value.uid,
author_name: currentUser$.value.displayName,
author_image: currentUser$.value.photoURL,
author: currentUser.uid,
author_name: currentUser.displayName,
author_image: currentUser.photoURL,
environment,
}
try {
await setDoc(
doc(
getFirestore(),
"users",
currentUser$.value.uid,
"environments",
"sync"
),
doc(getFirestore(), "users", currentUser.uid, "environments", "sync"),
ev
)
} catch (e) {
@@ -61,20 +57,22 @@ async function writeEnvironments(environment: Environment[]) {
}
async function writeGlobalEnvironment(variables: Environment["variables"]) {
if (currentUser$.value == null)
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("Cannot write global environment when signed out")
const ev = {
updatedOn: new Date(),
author: currentUser$.value.uid,
author_name: currentUser$.value.displayName,
author_image: currentUser$.value.photoURL,
author: currentUser.uid,
author_name: currentUser.displayName,
author_image: currentUser.photoURL,
variables,
}
try {
await setDoc(
doc(getFirestore(), "users", currentUser$.value.uid, "globalEnv", "sync"),
doc(getFirestore(), "users", currentUser.uid, "globalEnv", "sync"),
ev
)
} catch (e) {
@@ -84,9 +82,12 @@ async function writeGlobalEnvironment(variables: Environment["variables"]) {
}
export function initEnvironments() {
const currentUser$ = platform.auth.getCurrentUserStream()
const currentUser = platform.auth.getCurrentUser()
const envListenSub = environments$.subscribe((envs) => {
if (
currentUser$.value &&
currentUser &&
settingsStore.value.syncEnvironments &&
loadedEnvironments
) {
@@ -95,11 +96,7 @@ export function initEnvironments() {
})
const globalListenSub = globalEnv$.subscribe((vars) => {
if (
currentUser$.value &&
settingsStore.value.syncEnvironments &&
loadedGlobals
) {
if (currentUser && settingsStore.value.syncEnvironments && loadedGlobals) {
writeGlobalEnvironment(vars)
}
})

View File

@@ -12,7 +12,7 @@ import {
updateDoc,
} from "firebase/firestore"
import { FormDataKeyValue } from "@hoppscotch/data"
import { currentUser$ } from "./auth"
import { platform } from "~/platform"
import { getSettingSubject, settingsStore } from "~/newstore/settings"
import {
GQLHistoryEntry,
@@ -76,7 +76,9 @@ async function writeHistory(
? purgeFormDataFromRequest(entry as RESTHistoryEntry)
: entry
if (currentUser$.value == null)
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("User not logged in to sync history")
const hs = {
@@ -85,10 +87,7 @@ async function writeHistory(
}
try {
await addDoc(
collection(getFirestore(), "users", currentUser$.value.uid, col),
hs
)
await addDoc(collection(getFirestore(), "users", currentUser.uid, col), hs)
} catch (e) {
console.error("error writing to history", hs, e)
throw e
@@ -99,12 +98,14 @@ async function deleteHistory(
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
col: HistoryFBCollections
) {
if (currentUser$.value == null)
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("User not logged in to delete history")
try {
await deleteDoc(
doc(getFirestore(), "users", currentUser$.value.uid, col, entry.id)
doc(getFirestore(), "users", currentUser.uid, col, entry.id)
)
} catch (e) {
console.error("error deleting history", entry, e)
@@ -113,11 +114,13 @@ async function deleteHistory(
}
async function clearHistory(col: HistoryFBCollections) {
if (currentUser$.value == null)
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("User not logged in to clear history")
const { docs } = await getDocs(
collection(getFirestore(), "users", currentUser$.value.uid, col)
collection(getFirestore(), "users", currentUser.uid, col)
)
await Promise.all(docs.map((e) => deleteHistory(e as any, col)))
@@ -127,12 +130,13 @@ async function toggleStar(
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
col: HistoryFBCollections
) {
if (currentUser$.value == null)
throw new Error("User not logged in to toggle star")
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null) throw new Error("User not logged in to toggle star")
try {
await updateDoc(
doc(getFirestore(), "users", currentUser$.value.uid, col, entry.id),
doc(getFirestore(), "users", currentUser.uid, col, entry.id),
{ star: !entry.star }
)
} catch (e) {
@@ -142,12 +146,11 @@ async function toggleStar(
}
export function initHistory() {
const currentUser$ = platform.auth.getCurrentUserStream()
const currentUser = platform.auth.getCurrentUser()
const restHistorySub = restHistoryStore.dispatches$.subscribe((dispatch) => {
if (
loadedRESTHistory &&
currentUser$.value &&
settingsStore.value.syncHistory
) {
if (loadedRESTHistory && currentUser && settingsStore.value.syncHistory) {
if (dispatch.dispatcher === "addEntry") {
writeHistory(dispatch.payload.entry, "history")
} else if (dispatch.dispatcher === "deleteEntry") {
@@ -164,7 +167,7 @@ export function initHistory() {
(dispatch) => {
if (
loadedGraphqlHistory &&
currentUser$.value &&
currentUser &&
settingsStore.value.syncHistory
) {
if (dispatch.dispatcher === "addEntry") {

View File

@@ -1,6 +1,6 @@
import { initializeApp } from "firebase/app"
import { platform } from "~/platform"
import { initAnalytics } from "./analytics"
import { initAuth } from "./auth"
import { initCollections } from "./collections"
import { initEnvironments } from "./environments"
import { initHistory } from "./history"
@@ -24,7 +24,7 @@ export function initializeFirebase() {
try {
initializeApp(firebaseConfig)
initAuth()
platform.auth.performAuthInit()
initSettings()
initCollections()
initHistory()

View File

@@ -10,7 +10,8 @@ import {
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore"
import { cloneDeep } from "lodash-es"
import { HoppRESTRequest, translateToNewRequest } from "@hoppscotch/data"
import { currentUser$, HoppUser } from "./auth"
import { platform } from "~/platform"
import { HoppUser } from "~/platform/auth"
import { restRequest$ } from "~/newstore/RESTSession"
/**
@@ -44,7 +45,7 @@ function writeCurrentRequest(user: HoppUser, request: HoppRESTRequest) {
* @returns Fetched request object if exists else null
*/
export async function loadRequestFromSync(): Promise<HoppRESTRequest | null> {
const currentUser = currentUser$.value
const currentUser = platform.auth.getCurrentUser()
if (!currentUser)
throw new Error("Cannot load request from sync without login")
@@ -66,6 +67,8 @@ export async function loadRequestFromSync(): Promise<HoppRESTRequest | null> {
* Unsubscribe to stop syncing.
*/
export function startRequestSync(): Subscription {
const currentUser$ = platform.auth.getCurrentUserStream()
const sub = combineLatest([
currentUser$,
restRequest$.pipe(distinctUntilChanged()),

View File

@@ -5,7 +5,7 @@ import {
onSnapshot,
setDoc,
} from "firebase/firestore"
import { currentUser$ } from "./auth"
import { platform } from "~/platform"
import { applySetting, settingsStore, SettingsType } from "~/newstore/settings"
/**
@@ -20,21 +20,23 @@ let loadedSettings = false
* Write Transform
*/
async function writeSettings(setting: string, value: any) {
if (currentUser$.value === null)
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("Cannot write setting, user not signed in")
const st = {
updatedOn: new Date(),
author: currentUser$.value.uid,
author_name: currentUser$.value.displayName,
author_image: currentUser$.value.photoURL,
author: currentUser.uid,
author_name: currentUser.displayName,
author_image: currentUser.photoURL,
name: setting,
value,
}
try {
await setDoc(
doc(getFirestore(), "users", currentUser$.value.uid, "settings", setting),
doc(getFirestore(), "users", currentUser.uid, "settings", setting),
st
)
} catch (e) {
@@ -44,8 +46,11 @@ async function writeSettings(setting: string, value: any) {
}
export function initSettings() {
const currentUser$ = platform.auth.getCurrentUserStream()
const currentUser = platform.auth.getCurrentUser()
settingsStore.dispatches$.subscribe((dispatch) => {
if (currentUser$.value && loadedSettings) {
if (currentUser && loadedSettings) {
if (dispatch.dispatcher === "bulkApplySettings") {
Object.keys(dispatch.payload).forEach((key) => {
writeSettings(key, dispatch.payload[key])

View File

@@ -1,75 +0,0 @@
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"
import { BehaviorSubject } from "rxjs"
import { authIdToken$ } from "../fb/auth"
import { runGQLQuery } from "../backend/GQLClient"
import { GetUserInfoDocument } from "../backend/graphql"
/*
* This file deals with interfacing data provided by the
* Hoppscotch Backend server
*/
/**
* Defines the information provided about a user
*/
export interface UserInfo {
/**
* UID of the user
*/
uid: string
/**
* Displayable name of the user (or null if none available)
*/
displayName: string | null
/**
* Email of the user (or null if none available)
*/
email: string | null
/**
* URL to the profile photo of the user (or null if none available)
*/
photoURL: string | null
}
/**
* An observable subject onto the currently logged in user info (is null if not logged in)
*/
export const currentUserInfo$ = new BehaviorSubject<UserInfo | null>(null)
/**
* Initializes the currenUserInfo$ view and sets up its update mechanism
*/
export function initUserInfo() {
authIdToken$.subscribe((token) => {
if (token) {
updateUserInfo()
} else {
currentUserInfo$.next(null)
}
})
}
/**
* Runs the actual user info fetching
*/
async function updateUserInfo() {
const result = await runGQLQuery({
query: GetUserInfoDocument,
})
currentUserInfo$.next(
pipe(
result,
E.matchW(
() => null,
(x) => ({
uid: x.me.uid,
displayName: x.me.displayName ?? null,
email: x.me.email ?? null,
photoURL: x.me.photoURL ?? null,
})
)
)
)
}

View File

@@ -2,7 +2,7 @@ import * as E from "fp-ts/Either"
import { BehaviorSubject } from "rxjs"
import { GQLError, runGQLQuery } from "../backend/GQLClient"
import { GetMyTeamsDocument, GetMyTeamsQuery } from "../backend/graphql"
import { authIdToken$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
const BACKEND_PAGE_SIZE = 10
const POLL_DURATION = 10000
@@ -47,8 +47,10 @@ export default class TeamListAdapter {
}
async fetchList() {
const currentUser = platform.auth.getCurrentUser()
// if the authIdToken is not present, don't fetch the teams list, as it will fail anyway
if (!authIdToken$.value) return
if (!currentUser) return
this.loading$.next(true)

View File

@@ -1,8 +1,9 @@
import { createApp, Ref } from "vue"
import { createApp } from "vue"
import { PlatformDef, setPlatformDef } from "./platform"
import { setupLocalPersistence } from "./newstore/localpersistence"
import { performMigrations } from "./helpers/migrations"
import { initializeFirebase } from "./helpers/fb"
import { initUserInfo } from "./helpers/teams/BackendUserInfo"
import { initBackendGQLClient } from "./helpers/backend/GQLClient"
import { HOPP_MODULES } from "@modules/."
import "virtual:windi.css"
@@ -12,33 +13,16 @@ import "nprogress/nprogress.css"
import App from "./App.vue"
export type PlatformDef = {
ui?: {
appHeader?: {
paddingTop?: Ref<string>
paddingLeft?: Ref<string>
}
}
}
/**
* Defines the fields, functions and properties that will be
* filled in by the individual platforms.
*
* This value is populated upon calling `createHoppApp`
*/
export let platform: PlatformDef
export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
platform = platformDef
setPlatformDef(platformDef)
const app = createApp(App)
// Some basic work that needs to be done before module inits even
initializeFirebase()
initBackendGQLClient()
setupLocalPersistence()
performMigrations()
initUserInfo()
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))

View File

@@ -7,7 +7,7 @@ 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 { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
/**
* The tag names we allow giving to Sentry
@@ -164,6 +164,8 @@ function subscribeToAppEventsForReporting() {
* 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)

View File

@@ -10,8 +10,7 @@
import { defineComponent } from "vue"
import { useI18n } from "@composables/i18n"
import { initializeFirebase } from "~/helpers/fb"
import { isSignInWithEmailLink, signInWithEmailLink } from "~/helpers/fb/auth"
import { getLocalConfig, removeLocalConfig } from "~/newstore/localpersistence"
import { platform } from "~/platform"
export default defineComponent({
setup() {
@@ -29,29 +28,14 @@ export default defineComponent({
initializeFirebase()
},
async mounted() {
if (isSignInWithEmailLink(window.location.href)) {
this.signingInWithEmail = true
this.signingInWithEmail = true
let email = getLocalConfig("emailForSignIn")
if (!email) {
email = window.prompt(
"Please provide your email for confirmation"
) as string
}
await signInWithEmailLink(email, window.location.href)
.then(() => {
removeLocalConfig("emailForSignIn")
this.$router.push({ path: "/" })
})
.catch((e) => {
this.signingInWithEmail = false
this.error = e.message
})
.finally(() => {
this.signingInWithEmail = false
})
try {
await platform.auth.processMagicLink()
} catch (e) {
this.error = e.message
} finally {
this.signingInWithEmail = false
}
},
})

View File

@@ -156,7 +156,7 @@ import {
} from "~/helpers/backend/graphql"
import { acceptTeamInvitation } from "~/helpers/backend/mutations/TeamInvitation"
import { initializeFirebase } from "~/helpers/fb"
import { currentUser$, probableUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { onLoggedIn } from "@composables/auth"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
@@ -197,8 +197,15 @@ export default defineComponent({
}
})
const probableUser = useReadonlyStream(probableUser$, null)
const currentUser = useReadonlyStream(currentUser$, null)
const probableUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const loadingCurrentUser = computed(() => {
if (!probableUser.value) return false

View File

@@ -211,13 +211,9 @@
<script setup lang="ts">
import { ref, watchEffect, computed } from "vue"
import {
currentUser$,
probableUser$,
setDisplayName,
setEmailAddress,
verifyEmailAddress,
} from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { invokeAction } from "~/helpers/actions"
import { useReadonlyStream } from "@composables/stream"
@@ -247,8 +243,14 @@ usePageHead({
const SYNC_COLLECTIONS = useSetting("syncCollections")
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
const SYNC_HISTORY = useSetting("syncHistory")
const currentUser = useReadonlyStream(currentUser$, null)
const probableUser = useReadonlyStream(probableUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const probableUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
const loadingCurrentUser = computed(() => {
if (!probableUser.value) return false
@@ -262,7 +264,8 @@ watchEffect(() => (displayName.value = currentUser.value?.displayName))
const updateDisplayName = () => {
updatingDisplayName.value = true
setDisplayName(displayName.value as string)
platform.auth
.setDisplayName(displayName.value as string)
.then(() => {
toast.success(`${t("profile.updated")}`)
})
@@ -280,7 +283,8 @@ watchEffect(() => (emailAddress.value = currentUser.value?.email))
const updateEmailAddress = () => {
updatingEmailAddress.value = true
setEmailAddress(emailAddress.value as string)
platform.auth
.setEmailAddress(emailAddress.value as string)
.then(() => {
toast.success(`${t("profile.updated")}`)
})
@@ -296,7 +300,8 @@ const verifyingEmailAddress = ref(false)
const sendEmailVerification = () => {
verifyingEmailAddress.value = true
verifyEmailAddress()
platform.auth
.verifyEmailAddress()
.then(() => {
toast.success(`${t("profile.email_verification_mail")}`)
})

View File

@@ -0,0 +1,214 @@
import { ClientOptions } from "@urql/core"
import { Observable } from "rxjs"
/**
* A common (and required) set of fields that describe a user.
*/
export type HoppUser = {
/** A unique ID identifying the user */
uid: string
/** The name to be displayed as the user's */
displayName: string | null
/** The user's email address */
email: string | null
/** URL to the profile picture of the user */
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`) */
provider?: string
/** Access Token for the auth of the user against the given `provider`. */
accessToken?: string
emailVerified: boolean
}
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: "logout" } // No authentication and we have no previous state
export type GithubSignInResult =
| { type: "success"; user: HoppUser } // The authentication was a success
| { type: "account-exists-with-different-cred"; link: () => Promise<void> } // We authenticated correctly, but the provider didn't match, so we give the user the opportunity to link to continue completing auth
| { type: "error"; err: unknown } // Auth failed completely and we don't know why
export type AuthPlatformDef = {
/**
* Returns an observable that emits the current user as per the auth implementation.
*
* NOTES:
* 1. Make sure to emit non-null values once you have credentials to perform backend operations. (Get required tokens ?)
* 2. It is best to let the stream emit a value immediately on subscription (we can do that by basing this on a BehaviourSubject)
*
* @returns An observable which returns a `HoppUser` or null if not logged in (or login not completed)
*/
getCurrentUserStream: () => Observable<HoppUser | null>
/**
* Returns a stream to events happening in the auth mechanism. Common uses these events to
* let subsystems know something is changed by the authentication status and to react accordingly
*
* @returns An observable which emits an AuthEvent over time
*/
getAuthEventsStream: () => Observable<AuthEvent>
/**
* Similar to `getCurrentUserStream` but deals with the authentication being `probable`.
* Probable User for states where, "We haven't authed yet but we are guessing this person will auth eventually".
* This allows for things like Header component to presumpt a state until we auth properly and avoid flashing a "logged out" state.
*
* NOTES:
* 1. It is best to let the stream emit a value immediately on subscription (we can do that by basing this on a BehaviourSubject)
* 2. Once the authentication is confirmed, this stream should emit the same values as `getCurrentUserStream`.
*
* @returns An obsverable which returns a `HoppUser` for the probable user (or confirmed user if authed) or null if we don't know about a probable user
*/
getProbableUserStream: () => Observable<HoppUser | null>
/**
* Returns the currently authed user. (Similar rules apply as `getCurrentUserStream`)
* @returns The authenticated user or null if not logged in
*/
getCurrentUser: () => HoppUser | null
/**
* Returns the most probable to complete auth user. (Similar rules apply as `getProbableUserStream`)
* @returns The probable user or null if have no idea who will auth in
*/
getProbableUser: () => HoppUser | null
/**
* [This is only for Common Init logic to call!]
* Called by Common when it is time to perform initialization activities for authentication.
* (This is the best place to do init work for the auth subsystem in the platform).
*/
performAuthInit: () => void
/**
* Returns the headers that should be applied by the backend GQL API client (see GQLClient)
* inorder to talk to the backend (like apply auth headers ?)
* @returns An object with the header key and header values as strings
*/
getBackendHeaders: () => Record<string, string>
/**
* Called when the backend GQL API client encounters an auth error to check if with the
* current state, if an auth error is possible. This lets the backend GQL client know that
* it can expect an auth error and we should wait and (possibly retry) to re-execute an operation.
* This is useful for cases where queries might fail as the tokens just expired and need to be refreshed,
* so the app can get the new token and GQL client knows to re-execute the same query.
* @returns Whether an error is expected or not
*/
willBackendHaveAuthError: () => boolean
/**
* Used to register a callback where the backend GQL client should reconnect/reconfigure subscriptions
* as some communication parameter changed over time. Like for example, the backend subscription system
* on a id token based mechanism should be let known that the id token has changed and reconnect the subscription
* connection with the updated params.
* @param func The callback function to call
*/
onBackendGQLClientShouldReconnect: (func: () => void) => void
/**
* provide the client options for GqlClient
* @returns
*/
getGQLClientOptions?: () => ClientOptions
/**
* Returns the string content that should be returned when the user selects to
* copy auth token from Developer Options.
*
* @returns The auth token (or equivalent) as a string if we have one to give, else null
*/
getDevOptsBackendIDToken: () => string | null
/**
* Returns an empty promise that only resolves when the current probable user because confirmed.
*
* Note:
* 1. Make sure there is a probable user before waiting, as if not, this function will throw
* 2. If the probable user is already confirmed, this function will return an immediately resolved promise
*/
waitProbableLoginToConfirm: () => Promise<void>
/**
* Called to sign in user with email (magic link). This should send backend calls to send the auth email.
* @param email The email that is logging in.
* @returns An empty promise that is resolved when the operation is complete
*/
signInWithEmail: (email: string) => Promise<void>
/**
* Check whether a given link is a valid sign in with email, magic link response url.
* (i.e, a URL that COULD be from a magic link email)
* @param url The url to check
* @returns Whether this is valid or not (NOTE: This is just a structural check not whether this is accepted (hence, not async))
*/
isSignInWithEmailLink: (url: string) => boolean
/**
* Function that validates the magic link redirect and signs in the user
*
* @param email - Email to log in to
* @param url - The action URL which is used to validate login
* @returns A promise that resolves with the user info when auth is completed
*/
signInWithEmailLink: (email: string, url: string) => Promise<void>
/**
* function that validates the magic link & signs the user in
*/
processMagicLink: () => Promise<void>
/**
* Sends email verification email (the checkmark besides the email)
* @returns When the check has succeed and completed
*/
verifyEmailAddress: () => Promise<void>
/**
* Signs user in with Google.
* @returns A promise that resolves with the user info when auth is completed
*/
signInUserWithGoogle: () => Promise<void>
/**
* Signs user in with Github.
* @returns A promise that resolves with the auth status, giving an opportunity to link if or handle failures
*/
signInUserWithGithub: () => Promise<GithubSignInResult> | Promise<undefined>
/**
* Signs user in with Microsoft.
* @returns A promise that resolves with the user info when auth is completed
*/
signInUserWithMicrosoft: () => Promise<void>
/**
* Signs out the user from auth
* @returns An empty promise that is resolved when the operation is complete
*/
signOutUser: () => Promise<void>
/**
* Updates the email address of the user
* @param email The new email to set this to.
* @returns An empty promise that is resolved when the operation is complete
*/
setEmailAddress: (email: string) => Promise<void>
/**
* Updates the display name of the user
* @param name The new name to set this to.
* @returns An empty promise that is resolved when the operation is complete
*/
setDisplayName: (name: string) => Promise<void>
}

View File

@@ -0,0 +1,13 @@
import { AuthPlatformDef } from "./auth"
import { UIPlatformDef } from "./ui"
export type PlatformDef = {
ui?: UIPlatformDef
auth: AuthPlatformDef
}
export let platform: PlatformDef
export function setPlatformDef(def: PlatformDef) {
platform = def
}

View File

@@ -0,0 +1,8 @@
import { Ref } from "vue"
export type UIPlatformDef = {
appHeader?: {
paddingTop?: Ref<string>
paddingLeft?: Ref<string>
}
}

View File

@@ -2,6 +2,16 @@ import { HstVue } from "@histoire/plugin-vue"
import { defineConfig } from "histoire"
export default defineConfig({
theme: {
title: "Hoppscotch • UI",
logo: {
square: "/logo.png",
light: "/logo.png",
dark: "/logo.png",
},
// logoHref: "https://ui.hoppscotch.io",
favicon: 'favicon.ico',
},
setupFile: "histoire.setup.ts",
plugins: [HstVue()],
})

View File

@@ -6,7 +6,8 @@
"build": "vite build",
"story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview"
"story:preview": "histoire preview",
"do-build-ui": "pnpm run story:build"
},
"dependencies": {
"@hoppscotch/vue-toasted": "^0.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -0,0 +1,37 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
ButtonPrimary: typeof import('./components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./components/button/Secondary.vue')['default']
IconLucideLoader: typeof import('~icons/lucide/loader')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SmartAnchor: typeof import('./components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./components/smart/AutoComplete.vue')['default']
SmartCheckbox: typeof import('./components/smart/Checkbox.vue')['default']
SmartConfirmModal: typeof import('./components/smart/ConfirmModal.vue')['default']
SmartExpand: typeof import('./components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./components/smart/FileChip.vue')['default']
SmartIntersection: typeof import('./components/smart/Intersection.vue')['default']
SmartItem: typeof import('./components/smart/Item.vue')['default']
SmartLink: typeof import('./components/smart/Link.vue')['default']
SmartModal: typeof import('./components/smart/Modal.vue')['default']
SmartProgressRing: typeof import('./components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./components/smart/RadioGroup.vue')['default']
SmartSlideOver: typeof import('./components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./components/smart/Spinner.vue')['default']
SmartTab: typeof import('./components/smart/Tab.vue')['default']
SmartTabs: typeof import('./components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./components/smart/Toggle.vue')['default']
SmartWindow: typeof import('./components/smart/Window.vue')['default']
SmartWindows: typeof import('./components/smart/Windows.vue')['default']
}
}

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hoppscotch - Open source API development ecosystem</title>
<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" />

View File

@@ -21,7 +21,9 @@
"dependencies": {
"@hoppscotch/common": "workspace:^",
"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",

View File

@@ -0,0 +1,436 @@
import {
AuthEvent,
AuthPlatformDef,
HoppUser,
} from "@hoppscotch/common/platform/auth"
import {
Subscription,
BehaviorSubject,
Subject,
filter,
map,
combineLatest,
} from "rxjs"
import {
setDoc,
onSnapshot,
updateDoc,
doc,
getFirestore,
} from "firebase/firestore"
import {
AuthError,
AuthCredential,
User as FBUser,
sendSignInLinkToEmail,
linkWithCredential,
getAuth,
ActionCodeSettings,
isSignInWithEmailLink as isSignInWithEmailLinkFB,
signInWithEmailLink as signInWithEmailLinkFB,
sendEmailVerification,
signInWithPopup,
GoogleAuthProvider,
GithubAuthProvider,
OAuthProvider,
fetchSignInMethodsForEmail,
updateEmail,
updateProfile,
reauthenticateWithCredential,
onAuthStateChanged,
onIdTokenChanged,
signOut,
} from "firebase/auth"
import {
getLocalConfig,
removeLocalConfig,
setLocalConfig,
} from "@hoppscotch/common/newstore/localpersistence"
export const currentUserFB$ = new BehaviorSubject<FBUser | null>(null)
export const authEvents$ = new Subject<AuthEvent>()
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
const authIdToken$ = new BehaviorSubject<string | null>(null)
async function signInWithEmailLink(email: string, url: string) {
return await signInWithEmailLinkFB(getAuth(), email, url)
}
function fbUserToHoppUser(user: FBUser): HoppUser {
return {
uid: user.uid,
displayName: user.displayName,
email: user.email,
photoURL: user.photoURL,
emailVerified: user.emailVerified,
}
}
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
const EMAIL_ACTION_CODE_SETTINGS: ActionCodeSettings = {
url: `${import.meta.env.VITE_BASE_URL}/enter`,
handleCodeInApp: true,
}
async function signInUserWithGithubFB() {
return await signInWithPopup(
getAuth(),
new GithubAuthProvider().addScope("gist")
)
}
async function signInUserWithGoogleFB() {
return await signInWithPopup(getAuth(), new GoogleAuthProvider())
}
async function signInUserWithMicrosoftFB() {
return await signInWithPopup(getAuth(), new OAuthProvider("microsoft.com"))
}
/**
* Reauthenticate the user with the given credential
*/
async function reauthenticateUser() {
if (!currentUserFB$.value || !currentUser$.value)
throw new Error("No user has logged in")
const currentAuthMethod = currentUser$.value.provider
let credential
if (currentAuthMethod === "google.com") {
// const result = await signInUserWithGithubFB()
const result = await signInUserWithGoogleFB()
credential = GithubAuthProvider.credentialFromResult(result)
} else if (currentAuthMethod === "github.com") {
// const result = await signInUserWithGoogleFB()
const result = await signInUserWithGithubFB()
credential = GoogleAuthProvider.credentialFromResult(result)
} else if (currentAuthMethod === "microsoft.com") {
const result = await signInUserWithMicrosoftFB()
credential = OAuthProvider.credentialFromResult(result)
} else if (currentAuthMethod === "password") {
const email = prompt(
"Reauthenticate your account using your current email:"
)
await def
.signInWithEmail(email as string)
.then(() =>
alert(
`Check your inbox - we sent an email to ${email}. It contains a magic link that will reauthenticate your account.`
)
)
.catch((e) => {
alert(`Error: ${e.message}`)
console.error(e)
})
return
}
try {
await reauthenticateWithCredential(
currentUserFB$.value,
credential as AuthCredential
)
} catch (e) {
console.error("error updating", e)
throw e
}
}
/**
* Links account with another account given in a auth/account-exists-with-different-credential error
*
* @param error - Error caught after trying to login
*
* @returns Promise of UserCredential
*/
async function linkWithFBCredentialFromAuthError(error: unknown) {
// credential is not null since this function is called after an auth/account-exists-with-different-credential error, ie credentials actually exist
const credentials = OAuthProvider.credentialFromError(error as AuthError)!
const otherLinkedProviders = (
await getSignInMethodsForEmail((error as AuthError).customData.email!)
).filter((providerId) => credentials.providerId !== providerId)
let user: FBUser | null = null
if (otherLinkedProviders.indexOf("google.com") >= -1) {
user = (await signInUserWithGoogleFB()).user
} else if (otherLinkedProviders.indexOf("github.com") >= -1) {
user = (await signInUserWithGithubFB()).user
} else if (otherLinkedProviders.indexOf("microsoft.com") >= -1) {
user = (await signInUserWithMicrosoftFB()).user
}
// user is not null since going through each provider will return a user
return await linkWithCredential(user!, credentials)
}
async function setProviderInfo(id: string, token: string) {
if (!currentUser$.value) throw new Error("No user has logged in")
const us = {
updatedOn: new Date(),
provider: id,
accessToken: token,
}
try {
await updateDoc(doc(getFirestore(), "users", currentUser$.value.uid), us)
} catch (e) {
console.error("error updating provider info", e)
throw e
}
}
async function getSignInMethodsForEmail(email: string) {
return await fetchSignInMethodsForEmail(getAuth(), email)
}
export const def: AuthPlatformDef = {
getCurrentUserStream: () => currentUser$,
getAuthEventsStream: () => authEvents$,
getProbableUserStream: () => probableUser$,
getCurrentUser: () => currentUser$.value,
getProbableUser: () => probableUser$.value,
getBackendHeaders() {
return {
authorization: `Bearer ${authIdToken$.value}`,
}
},
willBackendHaveAuthError() {
return !authIdToken$.value
},
onBackendGQLClientShouldReconnect(func) {
authIdToken$.subscribe(() => {
func()
})
},
getDevOptsBackendIDToken() {
return authIdToken$.value
},
performAuthInit() {
// todo: implement
const auth = getAuth()
const firestore = getFirestore()
combineLatest([currentUserFB$, authIdToken$])
.pipe(
map(([user, token]) => {
// If there is no auth token, we will just consider as the auth as not complete
if (token === null) return null
if (user !== null) return fbUserToHoppUser(user)
return null
})
)
.subscribe((x) => {
currentUser$.next(x)
})
let extraSnapshotStop: (() => void) | null = null
probableUser$.next(JSON.parse(getLocalConfig("login_state") ?? "null"))
onAuthStateChanged(auth, (user) => {
const wasLoggedIn = currentUser$.value !== null
if (user) {
probableUser$.next(user)
} else {
probableUser$.next(null)
removeLocalConfig("login_state")
}
if (!user && extraSnapshotStop) {
extraSnapshotStop()
extraSnapshotStop = null
} else if (user) {
// Merge all the user info from all the authenticated providers
user.providerData.forEach((profile) => {
if (!profile) return
const us = {
updatedOn: new Date(),
provider: profile.providerId,
name: profile.displayName,
email: profile.email,
photoUrl: profile.photoURL,
uid: profile.uid,
}
setDoc(doc(firestore, "users", user.uid), us, { merge: true }).catch(
(e) => console.error("error updating", us, e)
)
})
extraSnapshotStop = onSnapshot(
doc(firestore, "users", user.uid),
(doc) => {
const data = doc.data()
const userUpdate: HoppUser = fbUserToHoppUser(user)
if (data) {
// Write extra provider data
userUpdate.provider = data.provider
userUpdate.accessToken = data.accessToken
}
currentUser$.next(userUpdate)
}
)
}
currentUserFB$.next(user)
currentUser$.next(user === null ? null : fbUserToHoppUser(user))
// User wasn't found before, but now is there (login happened)
if (!wasLoggedIn && user) {
authEvents$.next({
event: "login",
user: currentUser$.value!,
})
} else if (wasLoggedIn && !user) {
// User was found before, but now is not there (logout happened)
authEvents$.next({
event: "logout",
})
}
})
onIdTokenChanged(auth, async (user) => {
if (user) {
authIdToken$.next(await user.getIdToken())
setLocalConfig("login_state", JSON.stringify(user))
} else {
authIdToken$.next(null)
}
})
},
waitProbableLoginToConfirm() {
return new Promise<void>((resolve, reject) => {
if (authIdToken$.value) resolve()
if (!probableUser$.value) reject(new Error("no_probable_user"))
let sub: Subscription | null = null
sub = authIdToken$.pipe(filter((token) => !!token)).subscribe(() => {
sub?.unsubscribe()
resolve()
})
})
},
async signInWithEmail(email: string) {
return await sendSignInLinkToEmail(
getAuth(),
email,
EMAIL_ACTION_CODE_SETTINGS
)
},
isSignInWithEmailLink(url: string) {
return isSignInWithEmailLinkFB(getAuth(), url)
},
async verifyEmailAddress() {
if (!currentUserFB$.value) throw new Error("No user has logged in")
try {
await sendEmailVerification(currentUserFB$.value)
} catch (e) {
console.error("error verifying email address", e)
throw e
}
},
async signInUserWithGoogle() {
await signInUserWithGoogleFB()
},
async signInUserWithGithub() {
try {
const cred = await signInUserWithGithubFB()
const oAuthCred = GithubAuthProvider.credentialFromResult(cred)!
const token = oAuthCred.accessToken
await setProviderInfo(cred.providerId!, token!)
return {
type: "success",
user: fbUserToHoppUser(cred.user),
}
} catch (e) {
console.error("error while logging in with github", e)
if ((e as any).code === "auth/account-exists-with-different-credential") {
return {
type: "account-exists-with-different-cred",
link: async () => {
await linkWithFBCredentialFromAuthError(e)
},
}
} else {
return {
type: "error",
err: e,
}
}
}
},
async signInUserWithMicrosoft() {
await signInUserWithMicrosoftFB()
},
async signInWithEmailLink(email: string, url: string) {
await signInWithEmailLinkFB(getAuth(), email, url)
},
async setEmailAddress(email: string) {
if (!currentUserFB$.value) throw new Error("No user has logged in")
try {
await updateEmail(currentUserFB$.value, email)
} catch (e) {
await reauthenticateUser()
console.log("error setting email address", e)
throw e
}
},
async setDisplayName(name: string) {
if (!currentUserFB$.value) throw new Error("No user has logged in")
const us = {
displayName: name,
}
try {
await updateProfile(currentUserFB$.value, us)
} catch (e) {
console.error("error updating display name", e)
throw e
}
},
async signOutUser() {
if (!currentUser$.value) throw new Error("No user has logged in")
await signOut(getAuth())
},
async processMagicLink() {
if (this.isSignInWithEmailLink(window.location.href)) {
let email = getLocalConfig("emailForSignIn")
if (!email) {
email = window.prompt(
"Please provide your email for confirmation"
) as string
}
await signInWithEmailLink(email, window.location.href)
removeLocalConfig("emailForSignIn")
window.location.href = "/"
}
},
}

View File

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

47
pnpm-lock.yaml generated
View File

@@ -345,7 +345,7 @@ importers:
vite-plugin-inspect: 0.7.4_vite@3.1.4
vite-plugin-pages: 0.26.0_vnheu5mvzzbfbuhqo4shkhdhei
vite-plugin-pages-sitemap: 1.4.0
vite-plugin-pwa: 0.13.1_bg4cnt4dy3xq3a47wkujd6ryzq
vite-plugin-pwa: 0.13.1_vite@3.1.4
vite-plugin-vue-layouts: 0.7.0_oewzdqozxqnqgsrjzmwikx34vi
vite-plugin-windicss: 1.8.8_vite@3.1.4
vue-tsc: 0.38.2_typescript@4.7.4
@@ -561,7 +561,9 @@ importers:
eslint: ^8.28.0
eslint-plugin-prettier: ^4.2.1
eslint-plugin-vue: ^9.5.1
firebase: ^9.8.4
process: ^0.11.10
rxjs: ^7.5.5
stream-browserify: ^3.0.0
typescript: ^4.6.4
unplugin-icons: ^0.14.9
@@ -584,7 +586,9 @@ importers:
dependencies:
'@hoppscotch/common': link:../hoppscotch-common
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.45
@@ -610,7 +614,7 @@ importers:
vite-plugin-inspect: 0.7.4_vite@3.2.4
vite-plugin-pages: 0.26.0_vite@3.2.4
vite-plugin-pages-sitemap: 1.4.0
vite-plugin-pwa: 0.13.1_3kw35epztoiwny7qtfesjexvtu
vite-plugin-pwa: 0.13.1_vite@3.2.4
vite-plugin-static-copy: 0.12.0_vite@3.2.4
vite-plugin-vue-layouts: 0.7.0_vite@3.2.4+vue@3.2.45
vite-plugin-windicss: 1.8.8_vite@3.2.4
@@ -5566,7 +5570,7 @@ packages:
dev: true
/after/0.8.2:
resolution: {integrity: sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=}
resolution: {integrity: sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==}
dev: false
/agent-base/6.0.2:
@@ -5923,7 +5927,7 @@ packages:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
/base64-arraybuffer/0.1.4:
resolution: {integrity: sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=}
resolution: {integrity: sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==}
engines: {node: '>= 0.6.0'}
dev: false
@@ -6369,7 +6373,7 @@ packages:
dev: true
/component-bind/1.0.0:
resolution: {integrity: sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=}
resolution: {integrity: sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==}
dev: false
/component-emitter/1.3.0:
@@ -6377,7 +6381,7 @@ packages:
dev: false
/component-inherit/0.0.3:
resolution: {integrity: sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=}
resolution: {integrity: sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==}
dev: false
/concat-map/0.0.1:
@@ -8761,7 +8765,7 @@ packages:
dev: false
/has-cors/1.1.0:
resolution: {integrity: sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=}
resolution: {integrity: sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==}
dev: false
/has-flag/3.0.0:
@@ -9060,7 +9064,7 @@ packages:
engines: {node: '>=8'}
/indexof/0.0.1:
resolution: {integrity: sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=}
resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==}
dev: false
/inflight/1.0.6:
@@ -12712,7 +12716,7 @@ packages:
dev: true
/to-array/0.1.4:
resolution: {integrity: sha1-F+bBH3PdTz10zaek/zI46a2b+JA=}
resolution: {integrity: sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==}
dev: false
/to-fast-properties/2.0.0:
@@ -13742,29 +13746,10 @@ packages:
- supports-color
dev: true
/vite-plugin-pwa/0.13.1_3kw35epztoiwny7qtfesjexvtu:
/vite-plugin-pwa/0.13.1_vite@3.1.4:
resolution: {integrity: sha512-NR3dIa+o2hzlzo4lF4Gu0cYvoMjSw2DdRc6Epw1yjmCqWaGuN86WK9JqZie4arNlE1ZuWT3CLiMdiX5wcmmUmg==}
peerDependencies:
vite: ^3.1.0
workbox-window: ^6.5.4
dependencies:
debug: 4.3.4
fast-glob: 3.2.11
pretty-bytes: 6.0.0
rollup: 2.79.1
vite: 3.2.4
workbox-build: 6.5.4
workbox-window: 6.5.4
transitivePeerDependencies:
- '@types/babel__core'
- supports-color
dev: true
/vite-plugin-pwa/0.13.1_bg4cnt4dy3xq3a47wkujd6ryzq:
resolution: {integrity: sha512-NR3dIa+o2hzlzo4lF4Gu0cYvoMjSw2DdRc6Epw1yjmCqWaGuN86WK9JqZie4arNlE1ZuWT3CLiMdiX5wcmmUmg==}
peerDependencies:
vite: ^3.1.0
workbox-window: ^6.5.4
dependencies:
debug: 4.3.4
fast-glob: 3.2.11
@@ -13782,7 +13767,6 @@ packages:
resolution: {integrity: sha512-NR3dIa+o2hzlzo4lF4Gu0cYvoMjSw2DdRc6Epw1yjmCqWaGuN86WK9JqZie4arNlE1ZuWT3CLiMdiX5wcmmUmg==}
peerDependencies:
vite: ^3.1.0
workbox-window: ^6.5.4
dependencies:
debug: 4.3.4
fast-glob: 3.2.11
@@ -13790,6 +13774,7 @@ packages:
rollup: 2.79.1
vite: 3.2.4_sass@1.53.0
workbox-build: 6.5.4
workbox-window: 6.5.4
transitivePeerDependencies:
- '@types/babel__core'
- supports-color
@@ -14826,7 +14811,7 @@ packages:
dev: false
/yeast/0.1.2:
resolution: {integrity: sha1-AI4G2AlDIMNy28L47XagymyKxBk=}
resolution: {integrity: sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==}
dev: false
/yn/3.1.1: