feat: Agent registration UX flow updates (#4942)

Co-authored-by: curiouscorrelation <curiouscorrelation@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Nivedin 2025-03-27 21:09:23 +05:30 committed by GitHub
parent 3c535b2ad4
commit f564b2e34f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 170 additions and 107 deletions

View file

@ -181,6 +181,20 @@ pub fn run() {
.unwrap(); .unwrap();
}; };
let app_handle_ref = app_handle.clone();
app_handle.listen("maximize-window", move |_| {
tracing::info!("Maximize window event triggered");
if let Some(window) = app_handle_ref.get_webview_window("main") {
if let Err(e) = window.emit("show-otp-view", ()) {
tracing::error!("Failed to emit show-otp-view event: {}", e);
}
if let Err(e) = show_main_window(&app_handle_ref) {
tracing::error!("Failed to maximize window: {}", e);
}
}
});
let app_handle_ref = app_handle.clone(); let app_handle_ref = app_handle.clone();
app_handle.listen("registration-received", move |_| { app_handle.listen("registration-received", move |_| {
tracing::info!("Registration received event triggered"); tracing::info!("Registration received event triggered");

View file

@ -23,6 +23,13 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
true, true,
None::<&str>, None::<&str>,
)?; )?;
let maximize_window = MenuItem::with_id(
app,
"maximize_window",
"Maximize Window",
true,
None::<&str>,
)?;
let show_registrations = MenuItem::with_id( let show_registrations = MenuItem::with_id(
app, app,
"show_registrations", "show_registrations",
@ -48,9 +55,12 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
.item(&app_name_item) .item(&app_name_item)
.item(&app_version_item) .item(&app_version_item)
.separator() .separator()
.item(&maximize_window)
.separator()
.item(&clear_registrations) .item(&clear_registrations)
.item(&show_registrations) .item(&show_registrations)
.separator() .separator()
.separator()
.item(&quit_i) .item(&quit_i)
.build()?; .build()?;
@ -63,7 +73,7 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
}) })
.icon_as_template(cfg!(target_os = "macos")) .icon_as_template(cfg!(target_os = "macos"))
.menu(&menu) .menu(&menu)
.menu_on_left_click(true) .show_menu_on_left_click(true)
.on_menu_event(move |app, event| match event.id.as_ref() { .on_menu_event(move |app, event| match event.id.as_ref() {
"quit" => { "quit" => {
tracing::info!("Exiting the agent..."); tracing::info!("Exiting the agent...");
@ -85,6 +95,15 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
tracing::error!("Failed to show window: {}", e); tracing::error!("Failed to show window: {}", e);
} }
} }
"maximize_window" => {
app.emit("maximize-window", ())
.unwrap_or_else(|e| {
tracing::error!("Failed to emit maximize-window event: {}", e);
});
if let Err(e) = show_main_window(&app) {
tracing::error!("Failed to maximize window: {}", e);
}
}
_ => { _ => {
tracing::warn!("Unhandled menu event: {:?}", event.id); tracing::warn!("Unhandled menu event: {:?}", event.id);
} }

View file

@ -2,23 +2,18 @@
<div class="h-screen p-5 flex flex-col gap-y-2"> <div class="h-screen p-5 flex flex-col gap-y-2">
<h1 class="font-bold text-lg text-white">{{ pipe(state(), getTitle) }}</h1> <h1 class="font-bold text-lg text-white">{{ pipe(state(), getTitle) }}</h1>
<template v-if="isOtpView(state())"> <template v-if="O.isSome(state().otp)">
<div v-if="state().otp" class="flex-grow"> <div class="flex-grow">
<p class="tracking-wide"> <p class="tracking-wide">
An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into
the app to complete the registration process. Please hide the window if you did not initiate this request. the app to complete the registration process. Please cancel the registration if you did not initiate this request.
Do not hide this window until the verification code is entered. The window will hide automatically once done. The window will hide automatically once registration succeeds. If you minimize this window during registration,
you can access it again from the tray by selecting "Maximize Window".
</p> </p>
<p <p
class="font-bold text-5xl tracking-wider text-center pt-10 text-white" class="font-bold text-5xl tracking-wider text-center pt-10 text-white"
>{{ pipe(state().otp, O.getOrElse(() => "")) }}</p> >{{ pipe(state().otp, O.getOrElse(() => "")) }}</p>
</div> </div>
<div v-else class="text-center pt-10 flex-grow">
<p class="tracking-wide">Waiting for registration requests...</p>
<p
class="text-sm text-gray-400 mt-2"
>You can hide this window and access it again from the tray icon.</p>
</div>
</template> </template>
<template v-else> <template v-else>
@ -38,7 +33,27 @@
:icon="copyIcon" :icon="copyIcon"
@click="copyOtp" @click="copyOtp"
/> />
<HoppButtonPrimary label="Hide Window" outline @click="hideWindow" /> <div class="flex gap-2">
<template v-if="O.isSome(state().otp)">
<HoppButtonSecondary
label="Cancel Registration"
outline
@click="getCurrentWindow().close()"
/>
<HoppButtonPrimary
label="Minimize to Tray"
outline
@click="hideWindow"
/>
</template>
<HoppButtonPrimary
v-else
label="Minimize to Tray"
outline
@click="hideWindow"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -94,10 +109,9 @@ const appState = ref<AppState>({
const state = () => appState.value const state = () => appState.value
const isOtpView = (s: AppState): boolean => s.view === "otp"
const getTitle = (s: AppState): string => const getTitle = (s: AppState): string =>
s.view === "otp" ? "Agent Registration Request" : "Agent Registrations" O.isSome(s.otp) ? "Agent Registration Request" : "Agent Registrations"
const shouldShowCopy = (s: AppState): boolean => isOtpView(s) && O.isSome(s.otp) const shouldShowCopy = (s: AppState): boolean => O.isSome(s.otp)
const formatDate = (date: string): string => new Date(date).toLocaleString() const formatDate = (date: string): string => new Date(date).toLocaleString()
const getOtp = TE.tryCatch( const getOtp = TE.tryCatch(
@ -166,7 +180,11 @@ onMounted(async () => {
await pipe( await pipe(
getOtp, getOtp,
TE.map((otp: string) => { TE.map((otp: string) => {
if (otp) appState.value = { ...state(), otp: O.some(otp) } if (otp) {
appState.value = { ...state(), otp: O.some(otp) }
} else {
updateRegistrations();
}
}) })
)() )()
@ -181,6 +199,18 @@ onMounted(async () => {
), ),
listen("authenticated", handleAuthenticated), listen("authenticated", handleAuthenticated),
listen("show-registrations", handleShowRegistrations), listen("show-registrations", handleShowRegistrations),
listen("show-otp-view", async () => {
await pipe(
getOtp,
TE.map((otp: string) => {
if (otp) {
appState.value = { ...state(), otp: O.some(otp) };
} else {
updateRegistrations();
}
})
)();
}),
]) ])
}) })
</script> </script>

View file

@ -24,7 +24,13 @@
:icon="copyIcon" :icon="copyIcon"
@click="copyCode" @click="copyCode"
/> />
<HoppButtonPrimary label="Hide Window" outline @click="hideWindow" /> <div class="flex gap-2">
<HoppButtonSecondary
label="Cancel Registration"
outline
@click="getCurrentWindow().close()"
/>
</div>
</div> </div>
</div> </div>
</template> </template>

View file

@ -882,14 +882,15 @@
"account_email_description": "Your primary email address.", "account_email_description": "Your primary email address.",
"account_name_description": "This is your display name.", "account_name_description": "This is your display name.",
"additional": "Additional Settings", "additional": "Additional Settings",
"agent_not_running": "Hoppscotch Agent not detected - click `Retry` to check again.", "agent_not_running": "Hoppscotch Agent not detected. Please check if the Agent is running.",
"agent_not_running_short": "Check Agent's status.", "agent_not_running_short": "Check Agent's status.",
"agent_running": "Hoppscotch Agent is live.", "agent_running": "Hoppscotch Agent is live.",
"agent_running_short": "Hoppscotch Agent is live.", "agent_running_short": "Hoppscotch Agent is live.",
"agent_reset_registration": "Reset Registration", "agent_discard_registration": "Discard Agent Registration",
"agent_registered": "Agent Registered", "agent_registered": "Agent Registered",
"agent_registration_successful": "Agent Registered Successfully", "agent_registration_successful": "Agent Registered Successfully",
"agent_registration_fetch_failed": "Couldn't fetch Agent registration information", "agent_registration_fetch_failed": "Couldn't fetch Agent registration information. Please re-register the Agent.",
"agent_registration_already_in_progress": "Agent registration is already in progress. Please complete or cancel the current registration and try again.",
"auto_encode_mode": "Auto", "auto_encode_mode": "Auto",
"auto_encode_mode_tooltip": "Encode the parameters in the request only if some special characters are present", "auto_encode_mode_tooltip": "Encode the parameters in the request only if some special characters are present",
"background": "Background", "background": "Background",
@ -928,6 +929,7 @@
"proxy_url": "Proxy URL", "proxy_url": "Proxy URL",
"proxy_use_toggle": "Use the proxy middleware to send requests", "proxy_use_toggle": "Use the proxy middleware to send requests",
"read_the": "Read the", "read_the": "Read the",
"register_agent": "Register Agent",
"reset_default": "Use Default Proxy", "reset_default": "Use Default Proxy",
"short_codes": "Short codes", "short_codes": "Short codes",
"short_codes_description": "Short codes which were created by you.", "short_codes_description": "Short codes which were created by you.",

View file

@ -6,51 +6,16 @@
<div <div
v-if=" v-if="
!store.authKey.value && !store.authKey.value &&
(!store.isAgentRunning.value || !hasCheckedAgent) store.hasInitiatedRegistration.value &&
store.hasCheckedAgent.value
" "
class="flex flex-1 items-center space-x-2" class="flex flex-1 items-center space-x-2"
>
<div class="relative flex-1 border border-divider rounded p-2">
<span>{{ t("settings.agent_not_running_short") }}</span>
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.retry')"
:icon="IconRefresh"
outline
class="rounded"
@click="handleAgentCheck"
/>
</div>
<div
v-else-if="!store.authKey.value && !hasInitiatedRegistration"
class="flex flex-1 items-center space-x-2"
>
<div
class="relative flex-1 border border-divider rounded p-2 text-accent"
>
<span>{{ t("settings.agent_running") }}</span>
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.register')"
:icon="IconPlus"
outline
class="rounded"
@click="initiateRegistration"
/>
</div>
<div
v-else-if="!store.authKey.value"
class="flex flex-1 items-center space-x-2"
> >
<HoppSmartInput <HoppSmartInput
v-model="registrationOTP" v-model="store.registrationOTP.value"
:autofocus="false" :autofocus="false"
:placeholder="' '" :placeholder="' '"
:disabled="isRegistering" :disabled="store.isRegistering.value"
:label="t('settings.enter_otp')" :label="t('settings.enter_otp')"
input-styles="input floating-input" input-styles="input floating-input"
class="flex-1" class="flex-1"
@ -59,38 +24,50 @@
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('action.confirm')" :title="t('action.confirm')"
:icon="IconCheck" :icon="IconCheck"
:loading="isRegistering" :loading="store.isRegistering.value"
outline outline
class="rounded" class="rounded"
@click="register" @click="register"
/> />
</div> </div>
<div v-else class="flex relative flex-1 items-center space-x-2"> <div
v-else-if="store.maskedAuthKey.value"
class="flex relative flex-1 items-center space-x-2"
>
<label <label
class="text-secondaryLight text-tiny absolute -top-2 left-2 px-1 bg-primary" class="text-secondaryLight text-tiny absolute -top-2 left-2 px-1"
>{{ t("settings.agent_registered") }}</label >{{ t("settings.agent_registered") }}</label
> >
<div <div
class="w-full p-2 border border-dividerLight rounded bg-primary text-secondaryDark cursor-text select-all" class="w-full p-2 border border-dividerLight rounded bg-primary text-secondaryDark cursor-text select-all"
> >
{{ maskedAuthKey }} {{ store.maskedAuthKey.value }}
</div> </div>
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('settings.agent_reset_registration')" :title="t('settings.agent_discard_registration')"
:icon="iconClear" :icon="IconClose"
outline outline
class="rounded" class="rounded"
@click="resetRegistration" @click="resetRegistration"
/> />
</div> </div>
<div v-else>
<HoppButtonSecondary
:icon="IconPlus"
:label="t('settings.register_agent')"
outline
class="rounded"
@click="handleAgentCheck"
/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue" import { computed, onMounted } from "vue"
import { refAutoReset } from "@vueuse/core"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
@ -98,76 +75,77 @@ import { KernelInterceptorAgentStore } from "~/platform/std/kernel-interceptors/
import { KernelInterceptorService } from "~/services/kernel-interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import IconPlus from "~icons/lucide/plus" import IconPlus from "~icons/lucide/plus"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconCheck from "~icons/lucide/check" import IconCheck from "~icons/lucide/check"
import IconRefresh from "~icons/lucide/refresh-cw" import IconClose from "~icons/lucide/x"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
const store = useService(KernelInterceptorAgentStore) const store = useService(KernelInterceptorAgentStore)
const interceptorService = useService(KernelInterceptorService) const interceptorService = useService(KernelInterceptorService)
const iconClear = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
IconRotateCCW,
1000
)
const isSelected = computed( const isSelected = computed(
() => interceptorService.current.value?.id === "agent" () => interceptorService.current.value?.id === "agent"
) )
const hasInitiatedRegistration = ref(false) const handleAgentCheck = async () => {
const maskedAuthKey = ref("")
const hasCheckedAgent = ref(false)
const registrationOTP = ref(store.authKey.value ? null : "")
const isRegistering = ref(false)
async function handleAgentCheck() {
try { try {
await store.checkAgentStatus() await store.checkAgentStatus()
hasCheckedAgent.value = true store.hasCheckedAgent.value = true
if (!store.isAgentRunning.value) { if (!store.isAgentRunning.value) {
toast.error(t("settings.agent_not_running")) toast.error(t("settings.agent_not_running"))
} else {
await initiateRegistration()
} }
} catch (e) { } catch {
hasCheckedAgent.value = false store.hasCheckedAgent.value = false
toast.error(t("settings.agent_check_failed")) toast.error(t("settings.agent_check_failed"))
} }
} }
async function initiateRegistration() { const initiateRegistration = async () => {
try { try {
await store.initiateRegistration() await store.initiateRegistration()
hasInitiatedRegistration.value = true store.hasInitiatedRegistration.value = true
} catch (e) {} toast.success(t("settings.agent_running"))
} } catch (e: unknown) {
if (e instanceof Error) {
async function register() { if (e.message === "There is already an existing registration happening") {
if (!registrationOTP.value) return toast.error(t("settings.agent_registration_already_in_progress"))
isRegistering.value = true } else {
try { toast.error(t("settings.agent_registration_failed"))
await store.verifyRegistration(registrationOTP.value) }
await updateMaskedAuthKey() }
toast.success(t("settings.agent_registration_successful"))
registrationOTP.value = ""
} catch (e) {
} finally {
isRegistering.value = false
} }
} }
function resetRegistration() { const register = async () => {
store.authKey.value = null if (!store.registrationOTP.value) return
maskedAuthKey.value = "" store.isRegistering.value = true
registrationOTP.value = "" try {
hasInitiatedRegistration.value = false await store.verifyRegistration(store.registrationOTP.value)
await updateMaskedAuthKey()
toast.success(t("settings.agent_registration_successful"))
store.registrationOTP.value = ""
} catch (e) {
} finally {
store.isRegistering.value = false
}
} }
async function updateMaskedAuthKey() { const resetRegistration = async () => {
await store.resetAuthKey()
store.maskedAuthKey.value = ""
store.registrationOTP.value = ""
store.hasInitiatedRegistration.value = false
store.hasCheckedAgent.value = false
}
const updateMaskedAuthKey = async () => {
if (!store.authKey.value) return if (!store.authKey.value) return
try { try {
const registration = await store.fetchRegistrationInfo() const registration = await store.fetchRegistrationInfo()
maskedAuthKey.value = registration.auth_key_hash store.maskedAuthKey.value = registration.auth_key_hash
} catch (e) {} } catch (e) {}
} }

View file

@ -51,6 +51,13 @@ export class KernelInterceptorAgentStore extends Service {
public authKey = ref<string | null>(null) public authKey = ref<string | null>(null)
private sharedSecretB16 = ref<string | null>(null) private sharedSecretB16 = ref<string | null>(null)
// AgentSubtitle component shared variables for unified display across multiple components
public hasInitiatedRegistration = ref(false)
public maskedAuthKey = ref("")
public hasCheckedAgent = ref(false)
public registrationOTP = ref(this.authKey.value ? null : "")
public isRegistering = ref(false)
override async onServiceInit() { override async onServiceInit() {
const initResult = await Store.init() const initResult = await Store.init()
if (E.isLeft(initResult)) { if (E.isLeft(initResult)) {
@ -119,6 +126,12 @@ export class KernelInterceptorAgentStore extends Service {
} }
} }
public async resetAuthKey(): Promise<void> {
this.authKey.value = null
this.sharedSecretB16.value = null
await this.persistStore()
}
private mergeSecurity( private mergeSecurity(
...settings: (Required<InputDomainSetting>["security"] | undefined)[] ...settings: (Required<InputDomainSetting>["security"] | undefined)[]
): Required<InputDomainSetting>["security"] | undefined { ): Required<InputDomainSetting>["security"] | undefined {
@ -195,7 +208,7 @@ export class KernelInterceptorAgentStore extends Service {
) )
if (response.data.message !== "Registration received and stored") { if (response.data.message !== "Registration received and stored") {
throw new Error("Registration failed") throw new Error(response.data.message ?? "Registration failed")
} }
return otp return otp
@ -251,6 +264,7 @@ export class KernelInterceptorAgentStore extends Service {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
if (error.response?.status === 401) { if (error.response?.status === 401) {
this.authKey.value = null this.authKey.value = null
await this.persistStore()
} }
} }
throw error throw error