feat: hoppscotch agent and agent interceptor (#4396)

Co-authored-by: CuriousCorrelation <CuriousCorrelation@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Andrew Bastin
2024-10-03 20:26:30 +05:30
committed by GitHub
parent 0f27cf2d49
commit f75900ed30
106 changed files with 14636 additions and 609 deletions

View File

@@ -0,0 +1,153 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('agent.client_certs')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col space-y-4">
<ul
v-if="certificateMap.size > 0"
class="mx-4 border border-dividerDark rounded"
>
<li
v-for="([domain, certificate], index) in certificateMap"
:key="domain"
class="flex border-dividerDark px-2 items-center justify-between"
:class="{ 'border-t border-dividerDark': index !== 0 }"
>
<div class="flex space-x-2">
<div class="truncate">
{{ domain }}
</div>
</div>
<div class="flex items-center space-x-1">
<div class="text-secondaryLight mr-2">
{{ "PEMCert" in certificate.cert ? "PEM" : "PFX/PKCS12" }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
:title="
certificate.enabled
? t('action.turn_off')
: t('action.turn_on')
"
color="green"
@click="toggleEntryEnabled(domain)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
:title="t('action.remove')"
color="red"
@click="deleteEntry(domain)"
/>
</div>
</li>
</ul>
<HoppButtonSecondary
class="mx-4"
:icon="IconPlus"
:label="t('agent.add_cert_file')"
filled
outline
@click="showAddModal = true"
/>
</div>
</template>
<template #footer>
<div class="flex space-x-2">
<HoppButtonPrimary :label="t('action.save')" @click="save" />
<HoppButtonSecondary
:label="t('action.cancel')"
filled
outline
@click="emit('hide-modal')"
/>
</div>
</template>
</HoppSmartModal>
<InterceptorsAgentModalNativeClientCertsAdd
:show="showAddModal"
:existing-domains="Array.from(certificateMap.keys())"
@hide-modal="showAddModal = false"
@save="saveCertificate"
/>
</template>
<!-- TODO: i18n -->
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle"
import IconTrash from "~icons/lucide/trash"
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useService } from "dioc/vue"
import { cloneDeep } from "lodash-es"
import {
ClientCertificateEntry,
AgentInterceptorService,
} from "~/platform/std/interceptors/agent"
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const t = useI18n()
const nativeInterceptorService = useService(AgentInterceptorService)
const certificateMap = ref(new Map<string, ClientCertificateEntry>())
const showAddModal = ref(false)
watch(
() => props.show,
(show) => {
if (show) {
certificateMap.value = cloneDeep(
nativeInterceptorService.clientCertificates.value
)
}
}
)
function save() {
nativeInterceptorService.clientCertificates.value = cloneDeep(
certificateMap.value
)
emit("hide-modal")
}
function saveCertificate(cert: ClientCertificateEntry) {
certificateMap.value.set(cert.domain, cert)
}
function toggleEntryEnabled(domain: string) {
const certificate = certificateMap.value.get(domain)
if (certificate) {
certificateMap.value.set(domain, {
...certificate,
enabled: !certificate.enabled,
})
}
}
function deleteEntry(domain: string) {
certificateMap.value.delete(domain)
}
</script>

View File

@@ -0,0 +1,288 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('agent.add_client_cert')"
@close="emit('hide-modal')"
>
<template #body>
<div class="space-y-4">
<HoppSmartInput
v-model="domain"
:autofocus="false"
styles="flex-1"
placeholder=" "
:label="t('agent.domain')"
input-styles="input floating-input"
/>
<HoppSmartTabs v-model="selectedTab">
<HoppSmartTab :id="'pem'" :label="'PEM'">
<div class="p-4 space-y-4">
<div class="flex flex-col space-y-2">
<label> {{ t("agent.cert") }} </label>
<HoppButtonSecondary
:icon="pemCert?.type === 'loaded' ? IconFile : IconPlus"
:loading="pemCert?.type === 'loading'"
:label="
pemCert?.type === 'loaded'
? pemCert.filename
: t('agent.add_cert_file')
"
filled
outline
@click="openFilePicker('pem_cert')"
/>
</div>
<div class="flex flex-col space-y-2">
<label> {{ t("agent.key") }} </label>
<HoppButtonSecondary
:icon="pemKey?.type === 'loaded' ? IconFile : IconPlus"
:loading="pemKey?.type === 'loading'"
:label="
pemKey?.type === 'loaded'
? pemKey.filename
: t('agent.add_key_file')
"
filled
outline
@click="openFilePicker('pem_key')"
/>
</div>
</div>
</HoppSmartTab>
<HoppSmartTab :id="'pfx'" :label="t('agent.pfx_or_pkcs')">
<div class="p-4 space-y-6">
<div class="flex flex-col space-y-2">
<label> {{ t("agent.pfx_or_pkcs_file") }} </label>
<HoppButtonSecondary
:icon="pfxCert?.type === 'loaded' ? IconFile : IconPlus"
:loading="pfxCert?.type === 'loading'"
:label="
pfxCert?.type === 'loaded'
? pfxCert.filename
: t('agent.add_pfx_or_pkcs_file')
"
filled
outline
@click="openFilePicker('pfx_cert')"
/>
</div>
<div class="border border-divider rounded">
<HoppSmartInput
v-model="pfxPassword"
:type="showPfxPassword ? 'text' : 'password'"
:label="t('authorization.password')"
input-styles="floating-input !border-0 "
:placeholder="' '"
>
<template #button>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
showPfxPassword
? t('hide.password')
: t('show.password')
"
:icon="showPfxPassword ? IconEye : IconEyeOff"
@click="showPfxPassword = !showPfxPassword"
/>
</template>
</HoppSmartInput>
</div>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</div>
</template>
<template #footer>
<div class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.save')"
:disabled="!isValidCertificate || anyFileSelectorIsLoading"
@click="save"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
filled
outline
@click="emit('hide-modal')"
/>
</div>
</template>
</HoppSmartModal>
</template>
<!-- TODO: i18n -->
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconEyeOff from "~icons/lucide/eye-off"
import IconEye from "~icons/lucide/eye"
import IconFile from "~icons/lucide/file"
import { ref, watch, computed } from "vue"
import { useFileDialog } from "@vueuse/core"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { ClientCertificateEntry } from "~/platform/std/interceptors/agent"
const toast = useToast()
const props = defineProps<{
show: boolean
existingDomains: string[]
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "save", certificate: ClientCertificateEntry): void
}>()
type FileSelectorState =
| null
| { type: "loading" }
| { type: "loaded"; filename: string; data: Uint8Array }
const t = useI18n()
const domain = ref("")
const pemCert = ref<FileSelectorState>(null)
const pemKey = ref<FileSelectorState>(null)
const pfxCert = ref<FileSelectorState>(null)
const pfxPassword = ref("")
const showPfxPassword = ref(false)
const anyFileSelectorIsLoading = computed(
() =>
pemCert.value?.type === "loading" ||
pemKey.value?.type === "loading" ||
pfxCert.value?.type === "loading"
)
const currentlyPickingFile = ref<null | "pem_cert" | "pem_key" | "pfx_cert">(
null
)
const selectedTab = ref<"pem" | "pfx">("pem")
watch(
() => props.show,
(show) => {
if (!show) return
currentlyPickingFile.value = null
domain.value = ""
pemCert.value = null
pemKey.value = null
pfxCert.value = null
pfxPassword.value = ""
showPfxPassword.value = false
selectedTab.value = "pem"
}
)
const certificate = computed<ClientCertificateEntry | null>(() => {
if (selectedTab.value === "pem") {
if (pemCert.value?.type === "loaded" && pemKey.value?.type === "loaded") {
return <ClientCertificateEntry>{
domain: domain.value,
enabled: true,
cert: {
PEMCert: {
certificate_filename: pemCert.value.filename,
certificate_pem: pemCert.value.data,
key_filename: pemKey.value.filename,
key_pem: pemKey.value.data,
},
},
}
}
} else {
if (pfxCert.value?.type === "loaded") {
return <ClientCertificateEntry>{
domain: domain.value.trim(),
enabled: true,
cert: {
PFXCert: {
certificate_filename: pfxCert.value.filename,
certificate_pfx: pfxCert.value.data,
password: pfxPassword.value,
},
},
}
}
}
return null
})
const isValidCertificate = computed(() => {
if (certificate.value === null) return false
if (props.existingDomains.includes(certificate.value.domain)) {
toast.error("A certificate for this domain already exists")
return false
}
return ClientCertificateEntry.safeParse(certificate.value).success
})
const {
open: openFileDialog,
reset: resetFilePicker,
onChange: onFilePickerChange,
} = useFileDialog({
reset: true,
multiple: false,
})
onFilePickerChange(async (files) => {
if (!files) return
const file = files.item(0)
if (!file) return
if (currentlyPickingFile.value === "pem_cert") {
pemCert.value = { type: "loading" }
} else if (currentlyPickingFile.value === "pem_key") {
pemKey.value = { type: "loading" }
} else if (currentlyPickingFile.value === "pfx_cert") {
pfxCert.value = { type: "loading" }
}
const data = new Uint8Array(await file.arrayBuffer())
if (currentlyPickingFile.value === "pem_cert") {
pemCert.value = { type: "loaded", filename: file.name, data }
} else if (currentlyPickingFile.value === "pem_key") {
pemKey.value = { type: "loaded", filename: file.name, data }
} else if (currentlyPickingFile.value === "pfx_cert") {
pfxCert.value = { type: "loaded", filename: file.name, data }
}
currentlyPickingFile.value = null
resetFilePicker()
})
function openFilePicker(type: "pem_cert" | "pem_key" | "pfx_cert") {
currentlyPickingFile.value = type
openFileDialog()
}
function save() {
if (certificate.value) {
emit("save", certificate.value)
emit("hide-modal")
}
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<!-- TODO: i18n -->
<HoppSmartModal
v-if="show"
dialog
styles="sm:max-w-md"
:title="modalTitle"
@close="hideModal"
>
<template #body>
<div class="space-y-4">
<p v-if="status === 'agent_not_running'" class="text-secondaryLight">
{{ t("agent.not_running") }}
</p>
<template v-else-if="status === 'registration_required'">
<p
v-if="registrationStatus === 'initial'"
class="text-secondaryLight"
>
{{ t("agent.registration_instruction") }}
</p>
<template v-else-if="registrationStatus === 'otp_required'">
<p class="text-secondaryLight">
{{ t("agent.enter_otp_instruction") }}
</p>
<HoppSmartInput
v-model="userEnteredOTP"
placeholder=" "
:label="t('agent.otp_label')"
input-styles="input floating-input"
/>
</template>
<div
v-else-if="isRegistrationLoading"
class="flex items-center space-x-2"
>
<HoppSmartSpinner />
<p class="text-secondaryLight">{{ t("agent.processing") }}</p>
</div>
</template>
</div>
</template>
<template #footer>
<div class="flex justify-start flex-1">
<HoppButtonPrimary
:label="primaryButtonLabel"
:loading="isRegistrationLoading"
@click="primaryActionHandler"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
class="ml-2"
filled
outline
@click="hideModal"
/>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
const userEnteredOTP = ref("")
const props = defineProps<{
show: boolean
status: "agent_not_running" | "registration_required" | "hidden"
registrationStatus: "initial" | "otp_required" | "loading"
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "register"): void
(e: "verify", otp: string): void
(e: "retry-connection"): void
}>()
const modalTitle = computed(() => {
switch (props.status) {
case "agent_not_running":
return t("agent.not_running_title")
case "registration_required":
return t("agent.registration_title")
default:
return ""
}
})
const isRegistrationLoading = computed(
() => props.registrationStatus === "loading"
)
const primaryButtonLabel = computed(() => {
if (isRegistrationLoading.value) {
return t("state.loading")
}
if (props.status === "agent_not_running") {
return t("action.retry")
}
if (props.status === "registration_required") {
if (props.registrationStatus === "initial") {
return t("action.register")
}
if (props.registrationStatus === "otp_required") {
return t("action.verify")
}
}
return ""
})
const primaryActionHandler = () => {
if (props.status === "agent_not_running") {
return emit("retry-connection")
}
if (props.status === "registration_required") {
if (props.registrationStatus === "initial") {
return emit("register")
}
if (props.registrationStatus === "otp_required") {
return emit("verify", userEnteredOTP.value)
}
}
return null
}
const hideModal = () => emit("hide-modal")
</script>

View File

@@ -0,0 +1,100 @@
<template>
<InterceptorsAgentRegistrationModal
:show="showModal"
:status="modalStatus"
:registration-status="registrationStatus"
@hide-modal="hideModal"
@register="register"
@verify="verifyOTP"
@retry-connection="checkAgentStatus(true)"
/>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { AgentInterceptorService } from "~/platform/std/interceptors/agent"
import { ref, onMounted, computed, watch } from "vue"
import { useToast } from "@composables/toast"
import { InterceptorService } from "~/services/interceptor.service"
import { defineActionHandler } from "~/helpers/actions"
// TODO: Move as much as logic as possible to AgentInterceptorService
const interceptorService = useService(InterceptorService) // TODO: Try to remove dependency to InterceptorService
const agentService = useService(AgentInterceptorService)
const showModal = ref(false)
const toast = useToast()
const modalStatus = computed(() => {
if (!agentService.isAgentRunning.value) return "agent_not_running"
if (!agentService.isAuthKeyPresent()) return "registration_required"
return "hidden"
})
const registrationStatus = ref<"initial" | "otp_required" | "loading">(
"initial"
)
async function checkAgentStatus(isRetry = false) {
if (
interceptorService.currentInterceptor.value?.interceptorID ===
agentService.interceptorID
) {
await agentService.checkAgentStatus()
updateModalVisibility()
if (isRetry && !agentService.isAgentRunning.value) {
toast.error("Agent is not running.")
}
}
}
watch(interceptorService.currentInterceptor, () => {
checkAgentStatus()
})
function updateModalVisibility() {
showModal.value = modalStatus.value !== "hidden"
if (showModal.value && modalStatus.value === "registration_required") {
registrationStatus.value = "initial"
}
}
onMounted(async () => {
await checkAgentStatus()
})
function hideModal() {
showModal.value = false
}
async function register() {
registrationStatus.value = "loading"
try {
await agentService.initiateRegistration()
registrationStatus.value = "otp_required"
} catch (error) {
toast.error("Failed to initiate registration. Please try again.")
registrationStatus.value = "initial"
}
}
async function verifyOTP(otp: string) {
registrationStatus.value = "loading"
try {
await agentService.verifyRegistration(otp)
toast.success("Registration successful!")
hideModal()
} catch (error) {
toast.error("Failed to verify OTP. Please try again.")
registrationStatus.value = "otp_required"
}
}
defineActionHandler("agent.open-registration-modal", () => {
if (!showModal.value) {
showModal.value = true
registrationStatus.value = "initial"
}
})
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div class="py-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle
:on="allowSSLVerification"
@change="allowSSLVerification = !allowSSLVerification"
/>
{{ t("agent.verify_ssl_certs") }}
</div>
<div class="flex space-x-4">
<!--
<HoppButtonSecondary
:icon="IconLucideFileBadge"
:label="'CA Certificates'"
outline
@click="showCACertificatesModal = true"
/>
-->
<!--
<HoppButtonSecondary
:icon="IconLucideFileKey"
:label="t('agent.client_certs')"
outline
@click="showClientCertificatesModal = true"
/>
-->
</div>
<!--
<ModalsNativeCACertificates
:show="showCACertificatesModal"
@hide-modal="showCACertificatesModal = false"
/>
-->
<!--
<InterceptorsAgentModalNativeClientCertificates
:show="showClientCertificatesModal"
@hide-modal="showClientCertificatesModal = false"
/>
-->
<div class="pt-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle :on="allowProxy" @change="allowProxy = !allowProxy" />
{{ t("agent.use_http_proxy") }}
</div>
<HoppSmartInput
v-if="allowProxy"
v-model="proxyURL"
:autofocus="false"
styles="flex-1"
placeholder=" "
:label="t('settings.proxy_url')"
input-styles="input floating-input"
/>
<p class="my-1 text-secondaryLight">
{{ t("agent.proxy_capabilities") }}
</p>
</div>
</div>
</template>
<!-- TODO: i18n -->
<script setup lang="ts">
import { computed, ref } from "vue"
import { useI18n } from "@composables/i18n"
// import IconLucideFileKey from "~icons/lucide/file-key"
import { useService } from "dioc/vue"
import {
RequestDef,
AgentInterceptorService,
} from "~/platform/std/interceptors/agent"
import { syncRef } from "@vueuse/core"
type RequestProxyInfo = RequestDef["proxy"]
const t = useI18n()
const agentInterceptorService = useService(AgentInterceptorService)
const allowSSLVerification = agentInterceptorService.validateCerts
// const showCACertificatesModal = ref(false)
// const showClientCertificatesModal = ref(false)
const allowProxy = ref(false)
const proxyURL = ref("")
const proxyInfo = computed<RequestProxyInfo>({
get() {
if (allowProxy.value) {
return {
url: proxyURL.value,
}
}
return undefined
},
set(newData) {
if (newData) {
allowProxy.value = true
proxyURL.value = newData.url
} else {
allowProxy.value = false
}
},
})
syncRef(agentInterceptorService.proxyInfo, proxyInfo, { direction: "both" })
</script>