feat(desktop): cloud for orgs platform contract (#5903)

Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Shreyas 2026-02-24 23:21:48 +05:30 committed by GitHub
parent 5ae9639901
commit 02b3dbcf5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 339 additions and 64 deletions

View file

@ -952,7 +952,8 @@
"instance_changed": "Switched to instance",
"current_instance_error": "Failed to track current instance",
"not_available": "Instance switching is not available",
"cleanup_completed": "Instance switcher cleanup completed"
"cleanup_completed": "Instance switcher cleanup completed",
"self_hosted": "Self-hosted instances"
},
"inspections": {
"description": "Inspect possible errors",
@ -2246,6 +2247,7 @@
},
"organization_sidebar": {
"hoppscotch_cloud": "Hoppscotch Cloud",
"cloud_locked": "Default instance cannot be removed",
"admin": "Admin",
"error_loading": "Failed to load organizations",
"inactive_orgs": "Inactive Organizations",

View file

@ -73,7 +73,6 @@ declare module 'vue' {
CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default']
CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default']
CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.vue')['default']
CollectionsDocumentationSnapshotPreview: typeof import('./components/collections/documentation/SnapshotPreview.vue')['default']
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
@ -170,7 +169,6 @@ declare module 'vue' {
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSelectItem: typeof import('@hoppscotch/ui')['HoppSmartSelectItem']
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
@ -240,7 +238,6 @@ declare module 'vue' {
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBookOpen: typeof import('~icons/lucide/book-open')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheck: typeof import('~icons/lucide/check')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
@ -298,6 +295,7 @@ declare module 'vue' {
MockServerMockServerDashboard: typeof import('./components/mockServer/MockServerDashboard.vue')['default']
MockServerMockServerLogs: typeof import('./components/mockServer/MockServerLogs.vue')['default']
MonacoScriptEditor: typeof import('./components/MonacoScriptEditor.vue')['default']
OrganizationSwitcher: typeof import('./components/organization/Switcher.vue')['default']
Profile: typeof import('./components/profile/index.vue')['default']
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']

View file

@ -14,47 +14,17 @@
}"
>
<div class="flex">
<!-- Instance Switcher (Desktop/On-prem) -->
<!-- Unified Switcher (orgs + instances in one dropdown) -->
<tippy
v-if="platform.instance?.instanceSwitchingEnabled"
interactive
trigger="click"
theme="popover"
:on-shown="() => instanceSwitcherRef.focus()"
>
<div class="flex items-center cursor-pointer">
<span
class="!font-bold uppercase tracking-wide !text-secondaryDark pr-1"
>
{{
platform.instance.getCurrentInstance?.()?.displayName ||
"Hoppscotch"
}}
</span>
<IconChevronDown class="h-4 w-4 text-secondaryDark" />
</div>
<template #content="{ hide }">
<div
ref="instanceSwitcherRef"
class="flex flex-col focus:outline-none min-w-64"
tabindex="0"
@keyup.escape="hide()"
>
<InstanceSwitcher @close-dropdown="hide()" />
</div>
</template>
</tippy>
<!-- Organization Switcher (Web/Cloud) -->
<tippy
v-else-if="
platform.organization?.customOrganizationSwitcherComponent
v-if="
platform.organization?.customOrganizationSwitcherComponent ||
platform.instance?.instanceSwitchingEnabled
"
interactive
trigger="click"
theme="popover"
:on-shown="() => orgSwitcherRef?.focus()"
:on-create="onOrgSwitcherCreate"
:on-shown="() => switcherRef?.focus()"
:on-create="onSwitcherCreate"
>
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
@ -64,7 +34,7 @@
/>
<template #content="{ hide }">
<div
ref="orgSwitcherRef"
ref="switcherRef"
class="flex flex-col focus:outline-none min-w-72"
tabindex="0"
@keyup.escape="hide()"
@ -73,6 +43,13 @@
:is="
platform.organization.customOrganizationSwitcherComponent
"
v-if="
platform.organization?.customOrganizationSwitcherComponent
"
@close-dropdown="hide()"
/>
<InstanceSwitcher
v-if="platform.instance?.instanceSwitchingEnabled"
@close-dropdown="hide()"
/>
</div>
@ -424,13 +401,11 @@ const kernelMode = getKernelMode()
const downloadableLinksRef =
kernelMode === "web" ? ref<any | null>(null) : ref(null)
const instanceSwitcherRef =
kernelMode === "desktop" ? ref<any | null>(null) : ref(null)
const orgSwitcherRef = ref<HTMLElement | null>(null)
const switcherRef = ref<HTMLElement | null>(null)
// Reserve scrollbar gutter so content width doesn't shift when the list
// grows long enough to scroll inside the popover's `max-h-[45vh]` container.
const onOrgSwitcherCreate = (instance: Instance) => {
const onSwitcherCreate = (instance: Instance) => {
const content = instance.popper?.querySelector(".tippy-content")
if (content instanceof HTMLElement) {
content.style.scrollbarGutter = "stable"

View file

@ -11,6 +11,15 @@
v-else-if="isInstanceSwitchingEnabled"
class="flex flex-col space-y-1 w-full"
>
<!-- Section header -->
<div
class="flex items-center justify-between border-b border-dividerLight px-4 py-2"
>
<span class="text-xs text-secondary">
{{ t("instances.self_hosted") || "Self-hosted instances" }}
</span>
</div>
<div
v-if="connectedInstance"
class="flex items-center justify-between px-4 py-3 bg-accent text-accentContrast rounded-md"
@ -242,6 +251,7 @@ import type {
import IconLucideGlobe from "~icons/lucide/globe"
import IconLucideCheck from "~icons/lucide/check"
import IconLucideLock from "~icons/lucide/lock"
import IconLucideServer from "~icons/lucide/server"
import IconLucideTrash from "~icons/lucide/trash"
import IconLucideTrash2 from "~icons/lucide/trash-2"
@ -282,12 +292,20 @@ const isInstanceSwitchingEnabled = computed(() => {
})
const connectedInstance = computed(() => {
return isConnectedState(connectionState.value) ? currentInstance.value : null
if (!isConnectedState(connectionState.value)) return null
const instance = currentInstance.value
// cloud and cloud-org instances belong in the org section, not here
if (instance?.kind === "cloud" || instance?.kind === "cloud-org") return null
return instance
})
const recentInstances = computed(() => {
return recentInstancesList.value.filter(
(instance) => instance.serverUrl !== currentInstance.value?.serverUrl
(instance) =>
instance.serverUrl !== currentInstance.value?.serverUrl &&
// cloud and cloud-org instances are accessed via the dedicated cloud entry
instance.kind !== "cloud" &&
instance.kind !== "cloud-org"
)
})

View file

@ -0,0 +1,26 @@
<template>
<!-- Use custom component if platform provides one -->
<component
:is="platform.organization?.customOrganizationSwitcherComponent"
v-if="platform.organization?.customOrganizationSwitcherComponent"
@close-dropdown="$emit('close-dropdown')"
/>
<!-- Fallback: no default impl, org switching requires platform-specific service layer -->
<div v-else class="px-4 py-3">
<p class="text-xs text-secondary">
{{ t("organization_sidebar.no_orgs_found") }}
</p>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { platform } from "~/platform"
defineEmits<{
(e: "close-dropdown"): void
}>()
const t = useI18n()
</script>

View file

@ -0,0 +1,147 @@
import { describe, test, expect } from "vitest"
import {
getOrgInitials,
getOrgColorIndex,
getOrgColor,
ORG_AVATAR_COLORS,
sanitizeLogoUrl,
} from "../organization"
// pulls 1-2 uppercase initials from an org name for the avatar circle.
// the api can send back all sorts of garbage here (empty strings, extra whitespace,
// unicode names, etc.) so we gotta make sure we always spit out something the
// avatar component can actually render without blowing up.
describe("getOrgInitials", () => {
test("single word gives one letter", () => {
expect(getOrgInitials("Acme")).toBe("A")
})
test("two words gives two initials", () => {
expect(getOrgInitials("Acme Corp")).toBe("AC")
})
test("three+ words still caps at two initials", () => {
expect(getOrgInitials("My Cool Organization")).toBe("MC")
})
test("empty string falls back to question mark", () => {
expect(getOrgInitials("")).toBe("?")
})
test("whitespace-only also falls back to question mark", () => {
expect(getOrgInitials(" ")).toBe("?")
})
test("trims leading/trailing whitespace before extracting", () => {
expect(getOrgInitials(" Acme Corp ")).toBe("AC")
})
test("handles extra spaces between words", () => {
expect(getOrgInitials("Acme Corp")).toBe("AC")
})
test("lowercases get uppercased", () => {
expect(getOrgInitials("acme corp")).toBe("AC")
})
})
// deterministic color from a hash of the org name. same name should always
// map to the same color so the avatar circle doesn't randomly change color
// every time you re-render or navigate around the app.
describe("getOrgColorIndex", () => {
test("same name always produces the same index", () => {
const a = getOrgColorIndex("Acme Corp")
const b = getOrgColorIndex("Acme Corp")
expect(a).toBe(b)
})
test("different names produce different indices", () => {
const a = getOrgColorIndex("Acme Corp")
const b = getOrgColorIndex("Globex Industries")
expect(a).not.toBe(b)
})
test("result is always non-negative", () => {
const names = ["a", "zzz", "Org With Many Words In The Name", "日本語"]
for (const name of names) {
expect(getOrgColorIndex(name)).toBeGreaterThanOrEqual(0)
}
})
test("casing is normalized so 'Acme' and 'acme' get the same color", () => {
expect(getOrgColorIndex("Acme")).toBe(getOrgColorIndex("acme"))
})
})
describe("getOrgColor", () => {
test("returns a value from the ORG_AVATAR_COLORS palette", () => {
const color = getOrgColor("Acme Corp")
expect(ORG_AVATAR_COLORS).toContain(color)
})
test("deterministic: same name always same color", () => {
expect(getOrgColor("Test Org")).toBe(getOrgColor("Test Org"))
})
})
// xss gate for org logo urls. org admins can set whatever logo url they want,
// so we gotta make sure nobody sneaks in a `javascript:` or `vbscript:` url
// that would execute when we stick it in an `<img>` tag or anywhere else in
// the dom. also blocks `data:` urls that aren't actual images since those
// can carry scripts too.
describe("sanitizeLogoUrl", () => {
test("allows normal https URLs through", () => {
expect(sanitizeLogoUrl("https://example.com/logo.png")).toBe(
"https://example.com/logo.png"
)
})
test("allows http URLs through", () => {
expect(sanitizeLogoUrl("http://example.com/logo.png")).toBe(
"http://example.com/logo.png"
)
})
test("allows data:image URLs for file previews", () => {
const dataUrl = "data:image/png;base64,iVBORw0KGgo="
expect(sanitizeLogoUrl(dataUrl)).toBe(dataUrl)
})
test("allows blob URLs for local file objects", () => {
const blobUrl = "blob:http://localhost:3000/some-uuid"
expect(sanitizeLogoUrl(blobUrl)).toBe(blobUrl)
})
test("blocks javascript: protocol", () => {
expect(sanitizeLogoUrl("javascript:alert(1)")).toBe("")
})
test("blocks vbscript: protocol", () => {
expect(sanitizeLogoUrl("vbscript:MsgBox('hi')")).toBe("")
})
test("blocks data: URLs that aren't images", () => {
expect(sanitizeLogoUrl("data:text/html,<script>alert(1)</script>")).toBe("")
})
test("returns empty string for null/undefined", () => {
expect(sanitizeLogoUrl(null)).toBe("")
expect(sanitizeLogoUrl(undefined)).toBe("")
})
test("returns empty string for empty/whitespace strings", () => {
expect(sanitizeLogoUrl("")).toBe("")
expect(sanitizeLogoUrl(" ")).toBe("")
})
test("passes through relative URLs", () => {
expect(sanitizeLogoUrl("/logos/acme.png")).toBe("/logos/acme.png")
})
test("trims whitespace before checking", () => {
expect(sanitizeLogoUrl(" https://example.com/logo.png ")).toBe(
"https://example.com/logo.png"
)
})
})

View file

@ -35,7 +35,6 @@ export type PlatformDef = {
history: HistoryPlatformDef
}
kernelInterceptors: KernelInterceptorsPlatformDef
instance?: InstancePlatformDef
additionalInspectors?: InspectorsPlatformDef
spotlight?: SpotlightPlatformDef
platformFeatureFlags: {

View file

@ -46,13 +46,6 @@ export type InstancePlatformDef = {
*/
customInstanceSwitcherComponent?: Component
/**
* Component to render additional entries before the instances list.
* Desktop injects the "Hoppscotch Cloud" entry here, which resolves
* to the user's last-used org.
*/
additionalEntriesComponent?: Component
/**
* Returns an observable stream of the current connection state
*/

View file

@ -13,9 +13,10 @@ export type OrganizationPlatformDef = {
initiateOnboarding: () => void
/**
* Custom component for the organization switcher dropdown
* If provided, will be shown as a dropdown in the header (like the instance switcher)
* The component should emit 'close-dropdown' when the dropdown should close
* Custom component for the organization switcher dropdown.
* If provided, organization switching is considered enabled and the component
* will be rendered in the unified header dropdown alongside the instance switcher.
* The component should emit 'close-dropdown' when the dropdown should close.
*/
customOrganizationSwitcherComponent?: Component

View file

@ -0,0 +1,44 @@
# Capabilities Configuration
## Why wildcards are used in default.json
The `default.json` capability configuration uses wildcards for windows, webviews, and remote URLs:
```json
{
"windows": ["*"],
"webviews": ["*"],
"remote": {
"urls": ["app://*"]
}
}
```
### Rationale
**Cloud for Orgs Support**: The desktop app supports multi-tenancy where organizations get their own isolated contexts via dynamic hostnames. When a user switches to organization "acme", a new webview is created with URL `app://acme_hoppscotch_io/`. Since organization names are dynamic and user-defined, we cannot enumerate all possible window/webview labels or `app://` origins at build time.
**Security Considerations**:
1. **`app://` protocol is sandboxed**: The `app://` protocol is entirely handled by the tauri-plugin-appload plugin. External websites cannot inject content into this namespace. Only content served from the local bundle cache is accessible via `app://` URLs.
2. **No cross-origin access**: Each `app://` origin is isolated. A webview at `app://acme_hoppscotch_io/` cannot access content from `app://beta_hoppscotch_io/`.
3. **IPC commands are validated**: Tauri commands validate their inputs. The wildcard permission allows IPC calls from any `app://` origin, but the commands themselves enforce authorization.
**Alternatives Considered**:
- **Explicit patterns like `Hoppscotch-*`**: Tauri's capability system doesn't support glob patterns for window names in all contexts.
- **Pattern matching like `app://*_hoppscotch_io`**: Would require maintaining a list of allowed suffixes and wouldn't handle custom deployments.
### Previous Configuration
Before cloud-for-orgs support, the configuration used explicit window names:
```json
{
"windows": ["main", "Hoppscotch-curr", "Hoppscotch-next"]
}
```
This was more restrictive but incompatible with dynamic organization subdomains.

View file

@ -1,16 +1,19 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main",
"Hoppscotch-curr",
"Hoppscotch-next"
],
"description": "Capability for the main window and all app:// origins",
"windows": ["*"],
"webviews": ["*"],
"remote": {
"urls": ["app://*"]
},
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:event:default",
"core:path:default",
"core:webview:default",
"shell:allow-open",
"store:default",
"dialog:default",

View file

@ -92,3 +92,65 @@ pub(crate) fn init<R: Runtime>(port: u16, handle: tauri::AppHandle<R>) -> u16 {
port
}
// `AuthTokensQuery` gets deserialized from query params on the `/device-token`
// endpoint. the desktop app receives auth tokens this way after the user does
// browser-based login and gets redirected back. `refresh_token` is optional
// because some auth flows (like the device code flow) don't always return one,
// and we don't wanna blow up if it's missing.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_tokens_query_deserialize_both_tokens() {
let json = r#"{"access_token":"abc123","refresh_token":"def456"}"#;
let query: AuthTokensQuery = serde_json::from_str(json).unwrap();
assert_eq!(query.access_token, "abc123");
assert_eq!(query.refresh_token, Some("def456".to_string()));
}
#[test]
fn test_auth_tokens_query_deserialize_without_refresh() {
let json = r#"{"access_token":"abc123"}"#;
let query: AuthTokensQuery = serde_json::from_str(json).unwrap();
assert_eq!(query.access_token, "abc123");
assert_eq!(query.refresh_token, None);
}
#[test]
fn test_auth_tokens_query_deserialize_fails_without_access_token() {
let json = r#"{"refresh_token":"def456"}"#;
let result: Result<AuthTokensQuery, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_auth_tokens_query_roundtrip() {
let original = AuthTokensQuery {
access_token: "token_value".to_string(),
refresh_token: Some("refresh_value".to_string()),
};
let serialized = serde_json::to_value(&original).unwrap();
assert_eq!(serialized["access_token"], "token_value");
assert_eq!(serialized["refresh_token"], "refresh_value");
let deserialized: AuthTokensQuery =
serde_json::from_value(serialized).unwrap();
assert_eq!(deserialized.access_token, original.access_token);
assert_eq!(deserialized.refresh_token, original.refresh_token);
}
#[test]
fn test_auth_tokens_query_null_refresh_token() {
let json = r#"{"access_token":"abc123","refresh_token":null}"#;
let query: AuthTokensQuery = serde_json::from_str(json).unwrap();
assert_eq!(query.access_token, "abc123");
assert_eq!(query.refresh_token, None);
}
}

View file

@ -104,8 +104,15 @@ export function useAppInitialization() {
await download({ serverUrl: instance.serverUrl })
// cloud-org instances pass serverUrl as host so window.location.hostname reflects the
// org subdomain (like acme.hoppscotch.io). This becomes the source of truth for org
// context throughout the app instead of needing to pass state through multiple layers.
const host =
instance.kind === "cloud-org" ? instance.serverUrl : undefined
const loadResp = await load({
bundleName: instance.bundleName!,
host,
window: { title: "Hoppscotch" },
})