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();
};
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();
app_handle.listen("registration-received", move |_| {
tracing::info!("Registration received event triggered");

View file

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

View file

@ -2,23 +2,18 @@
<div class="h-screen p-5 flex flex-col gap-y-2">
<h1 class="font-bold text-lg text-white">{{ pipe(state(), getTitle) }}</h1>
<template v-if="isOtpView(state())">
<div v-if="state().otp" class="flex-grow">
<template v-if="O.isSome(state().otp)">
<div class="flex-grow">
<p class="tracking-wide">
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.
Do not hide this window until the verification code is entered. The window will hide automatically once done.
the app to complete the registration process. Please cancel the registration if you did not initiate this request.
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
class="font-bold text-5xl tracking-wider text-center pt-10 text-white"
>{{ pipe(state().otp, O.getOrElse(() => "")) }}</p>
</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 v-else>
@ -38,7 +33,27 @@
:icon="copyIcon"
@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>
</template>
@ -94,10 +109,9 @@ const appState = ref<AppState>({
const state = () => appState.value
const isOtpView = (s: AppState): boolean => s.view === "otp"
const getTitle = (s: AppState): string =>
s.view === "otp" ? "Agent Registration Request" : "Agent Registrations"
const shouldShowCopy = (s: AppState): boolean => isOtpView(s) && O.isSome(s.otp)
O.isSome(s.otp) ? "Agent Registration Request" : "Agent Registrations"
const shouldShowCopy = (s: AppState): boolean => O.isSome(s.otp)
const formatDate = (date: string): string => new Date(date).toLocaleString()
const getOtp = TE.tryCatch(
@ -166,7 +180,11 @@ onMounted(async () => {
await pipe(
getOtp,
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("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>

View file

@ -24,7 +24,13 @@
:icon="copyIcon"
@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>
</template>

View file

@ -882,14 +882,15 @@
"account_email_description": "Your primary email address.",
"account_name_description": "This is your display name.",
"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_running": "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_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_tooltip": "Encode the parameters in the request only if some special characters are present",
"background": "Background",
@ -928,6 +929,7 @@
"proxy_url": "Proxy URL",
"proxy_use_toggle": "Use the proxy middleware to send requests",
"read_the": "Read the",
"register_agent": "Register Agent",
"reset_default": "Use Default Proxy",
"short_codes": "Short codes",
"short_codes_description": "Short codes which were created by you.",

View file

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

View file

@ -51,6 +51,13 @@ export class KernelInterceptorAgentStore extends Service {
public authKey = 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() {
const initResult = await Store.init()
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(
...settings: (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") {
throw new Error("Registration failed")
throw new Error(response.data.message ?? "Registration failed")
}
return otp
@ -251,6 +264,7 @@ export class KernelInterceptorAgentStore extends Service {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
this.authKey.value = null
await this.persistStore()
}
}
throw error