Skip to content

Commit 02b3dbc

Browse files
feat(desktop): cloud for orgs platform contract (hoppscotch#5903)
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
1 parent 5ae9639 commit 02b3dbc

13 files changed

Lines changed: 339 additions & 64 deletions

File tree

packages/hoppscotch-common/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -952,7 +952,8 @@
952952
"instance_changed": "Switched to instance",
953953
"current_instance_error": "Failed to track current instance",
954954
"not_available": "Instance switching is not available",
955-
"cleanup_completed": "Instance switcher cleanup completed"
955+
"cleanup_completed": "Instance switcher cleanup completed",
956+
"self_hosted": "Self-hosted instances"
956957
},
957958
"inspections": {
958959
"description": "Inspect possible errors",
@@ -2246,6 +2247,7 @@
22462247
},
22472248
"organization_sidebar": {
22482249
"hoppscotch_cloud": "Hoppscotch Cloud",
2250+
"cloud_locked": "Default instance cannot be removed",
22492251
"admin": "Admin",
22502252
"error_loading": "Failed to load organizations",
22512253
"inactive_orgs": "Inactive Organizations",

packages/hoppscotch-common/src/components.d.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ declare module 'vue' {
7373
CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default']
7474
CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default']
7575
CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.vue')['default']
76-
CollectionsDocumentationSnapshotPreview: typeof import('./components/collections/documentation/SnapshotPreview.vue')['default']
7776
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
7877
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
7978
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
@@ -170,7 +169,6 @@ declare module 'vue' {
170169
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
171170
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
172171
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
173-
HoppSmartSelectItem: typeof import('@hoppscotch/ui')['HoppSmartSelectItem']
174172
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
175173
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
176174
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
@@ -240,7 +238,6 @@ declare module 'vue' {
240238
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
241239
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
242240
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
243-
IconLucideBookOpen: typeof import('~icons/lucide/book-open')['default']
244241
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
245242
IconLucideCheck: typeof import('~icons/lucide/check')['default']
246243
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
@@ -298,6 +295,7 @@ declare module 'vue' {
298295
MockServerMockServerDashboard: typeof import('./components/mockServer/MockServerDashboard.vue')['default']
299296
MockServerMockServerLogs: typeof import('./components/mockServer/MockServerLogs.vue')['default']
300297
MonacoScriptEditor: typeof import('./components/MonacoScriptEditor.vue')['default']
298+
OrganizationSwitcher: typeof import('./components/organization/Switcher.vue')['default']
301299
Profile: typeof import('./components/profile/index.vue')['default']
302300
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
303301
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']

packages/hoppscotch-common/src/components/app/Header.vue

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,47 +14,17 @@
1414
}"
1515
>
1616
<div class="flex">
17-
<!-- Instance Switcher (Desktop/On-prem) -->
17+
<!-- Unified Switcher (orgs + instances in one dropdown) -->
1818
<tippy
19-
v-if="platform.instance?.instanceSwitchingEnabled"
20-
interactive
21-
trigger="click"
22-
theme="popover"
23-
:on-shown="() => instanceSwitcherRef.focus()"
24-
>
25-
<div class="flex items-center cursor-pointer">
26-
<span
27-
class="!font-bold uppercase tracking-wide !text-secondaryDark pr-1"
28-
>
29-
{{
30-
platform.instance.getCurrentInstance?.()?.displayName ||
31-
"Hoppscotch"
32-
}}
33-
</span>
34-
<IconChevronDown class="h-4 w-4 text-secondaryDark" />
35-
</div>
36-
<template #content="{ hide }">
37-
<div
38-
ref="instanceSwitcherRef"
39-
class="flex flex-col focus:outline-none min-w-64"
40-
tabindex="0"
41-
@keyup.escape="hide()"
42-
>
43-
<InstanceSwitcher @close-dropdown="hide()" />
44-
</div>
45-
</template>
46-
</tippy>
47-
48-
<!-- Organization Switcher (Web/Cloud) -->
49-
<tippy
50-
v-else-if="
51-
platform.organization?.customOrganizationSwitcherComponent
19+
v-if="
20+
platform.organization?.customOrganizationSwitcherComponent ||
21+
platform.instance?.instanceSwitchingEnabled
5222
"
5323
interactive
5424
trigger="click"
5525
theme="popover"
56-
:on-shown="() => orgSwitcherRef?.focus()"
57-
:on-create="onOrgSwitcherCreate"
26+
:on-shown="() => switcherRef?.focus()"
27+
:on-create="onSwitcherCreate"
5828
>
5929
<HoppButtonSecondary
6030
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
@@ -64,7 +34,7 @@
6434
/>
6535
<template #content="{ hide }">
6636
<div
67-
ref="orgSwitcherRef"
37+
ref="switcherRef"
6838
class="flex flex-col focus:outline-none min-w-72"
6939
tabindex="0"
7040
@keyup.escape="hide()"
@@ -73,6 +43,13 @@
7343
:is="
7444
platform.organization.customOrganizationSwitcherComponent
7545
"
46+
v-if="
47+
platform.organization?.customOrganizationSwitcherComponent
48+
"
49+
@close-dropdown="hide()"
50+
/>
51+
<InstanceSwitcher
52+
v-if="platform.instance?.instanceSwitchingEnabled"
7653
@close-dropdown="hide()"
7754
/>
7855
</div>
@@ -424,13 +401,11 @@ const kernelMode = getKernelMode()
424401
425402
const downloadableLinksRef =
426403
kernelMode === "web" ? ref<any | null>(null) : ref(null)
427-
const instanceSwitcherRef =
428-
kernelMode === "desktop" ? ref<any | null>(null) : ref(null)
429-
const orgSwitcherRef = ref<HTMLElement | null>(null)
404+
const switcherRef = ref<HTMLElement | null>(null)
430405
431406
// Reserve scrollbar gutter so content width doesn't shift when the list
432407
// grows long enough to scroll inside the popover's `max-h-[45vh]` container.
433-
const onOrgSwitcherCreate = (instance: Instance) => {
408+
const onSwitcherCreate = (instance: Instance) => {
434409
const content = instance.popper?.querySelector(".tippy-content")
435410
if (content instanceof HTMLElement) {
436411
content.style.scrollbarGutter = "stable"

packages/hoppscotch-common/src/components/instance/Switcher.vue

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
v-else-if="isInstanceSwitchingEnabled"
1212
class="flex flex-col space-y-1 w-full"
1313
>
14+
<!-- Section header -->
15+
<div
16+
class="flex items-center justify-between border-b border-dividerLight px-4 py-2"
17+
>
18+
<span class="text-xs text-secondary">
19+
{{ t("instances.self_hosted") || "Self-hosted instances" }}
20+
</span>
21+
</div>
22+
1423
<div
1524
v-if="connectedInstance"
1625
class="flex items-center justify-between px-4 py-3 bg-accent text-accentContrast rounded-md"
@@ -242,6 +251,7 @@ import type {
242251
243252
import IconLucideGlobe from "~icons/lucide/globe"
244253
import IconLucideCheck from "~icons/lucide/check"
254+
import IconLucideLock from "~icons/lucide/lock"
245255
import IconLucideServer from "~icons/lucide/server"
246256
import IconLucideTrash from "~icons/lucide/trash"
247257
import IconLucideTrash2 from "~icons/lucide/trash-2"
@@ -282,12 +292,20 @@ const isInstanceSwitchingEnabled = computed(() => {
282292
})
283293
284294
const connectedInstance = computed(() => {
285-
return isConnectedState(connectionState.value) ? currentInstance.value : null
295+
if (!isConnectedState(connectionState.value)) return null
296+
const instance = currentInstance.value
297+
// cloud and cloud-org instances belong in the org section, not here
298+
if (instance?.kind === "cloud" || instance?.kind === "cloud-org") return null
299+
return instance
286300
})
287301
288302
const recentInstances = computed(() => {
289303
return recentInstancesList.value.filter(
290-
(instance) => instance.serverUrl !== currentInstance.value?.serverUrl
304+
(instance) =>
305+
instance.serverUrl !== currentInstance.value?.serverUrl &&
306+
// cloud and cloud-org instances are accessed via the dedicated cloud entry
307+
instance.kind !== "cloud" &&
308+
instance.kind !== "cloud-org"
291309
)
292310
})
293311
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<template>
2+
<!-- Use custom component if platform provides one -->
3+
<component
4+
:is="platform.organization?.customOrganizationSwitcherComponent"
5+
v-if="platform.organization?.customOrganizationSwitcherComponent"
6+
@close-dropdown="$emit('close-dropdown')"
7+
/>
8+
9+
<!-- Fallback: no default impl, org switching requires platform-specific service layer -->
10+
<div v-else class="px-4 py-3">
11+
<p class="text-xs text-secondary">
12+
{{ t("organization_sidebar.no_orgs_found") }}
13+
</p>
14+
</div>
15+
</template>
16+
17+
<script setup lang="ts">
18+
import { useI18n } from "@composables/i18n"
19+
import { platform } from "~/platform"
20+
21+
defineEmits<{
22+
(e: "close-dropdown"): void
23+
}>()
24+
25+
const t = useI18n()
26+
</script>
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, test, expect } from "vitest"
2+
3+
import {
4+
getOrgInitials,
5+
getOrgColorIndex,
6+
getOrgColor,
7+
ORG_AVATAR_COLORS,
8+
sanitizeLogoUrl,
9+
} from "../organization"
10+
11+
// pulls 1-2 uppercase initials from an org name for the avatar circle.
12+
// the api can send back all sorts of garbage here (empty strings, extra whitespace,
13+
// unicode names, etc.) so we gotta make sure we always spit out something the
14+
// avatar component can actually render without blowing up.
15+
describe("getOrgInitials", () => {
16+
test("single word gives one letter", () => {
17+
expect(getOrgInitials("Acme")).toBe("A")
18+
})
19+
20+
test("two words gives two initials", () => {
21+
expect(getOrgInitials("Acme Corp")).toBe("AC")
22+
})
23+
24+
test("three+ words still caps at two initials", () => {
25+
expect(getOrgInitials("My Cool Organization")).toBe("MC")
26+
})
27+
28+
test("empty string falls back to question mark", () => {
29+
expect(getOrgInitials("")).toBe("?")
30+
})
31+
32+
test("whitespace-only also falls back to question mark", () => {
33+
expect(getOrgInitials(" ")).toBe("?")
34+
})
35+
36+
test("trims leading/trailing whitespace before extracting", () => {
37+
expect(getOrgInitials(" Acme Corp ")).toBe("AC")
38+
})
39+
40+
test("handles extra spaces between words", () => {
41+
expect(getOrgInitials("Acme Corp")).toBe("AC")
42+
})
43+
44+
test("lowercases get uppercased", () => {
45+
expect(getOrgInitials("acme corp")).toBe("AC")
46+
})
47+
})
48+
49+
// deterministic color from a hash of the org name. same name should always
50+
// map to the same color so the avatar circle doesn't randomly change color
51+
// every time you re-render or navigate around the app.
52+
describe("getOrgColorIndex", () => {
53+
test("same name always produces the same index", () => {
54+
const a = getOrgColorIndex("Acme Corp")
55+
const b = getOrgColorIndex("Acme Corp")
56+
expect(a).toBe(b)
57+
})
58+
59+
test("different names produce different indices", () => {
60+
const a = getOrgColorIndex("Acme Corp")
61+
const b = getOrgColorIndex("Globex Industries")
62+
expect(a).not.toBe(b)
63+
})
64+
65+
test("result is always non-negative", () => {
66+
const names = ["a", "zzz", "Org With Many Words In The Name", "日本語"]
67+
for (const name of names) {
68+
expect(getOrgColorIndex(name)).toBeGreaterThanOrEqual(0)
69+
}
70+
})
71+
72+
test("casing is normalized so 'Acme' and 'acme' get the same color", () => {
73+
expect(getOrgColorIndex("Acme")).toBe(getOrgColorIndex("acme"))
74+
})
75+
})
76+
77+
describe("getOrgColor", () => {
78+
test("returns a value from the ORG_AVATAR_COLORS palette", () => {
79+
const color = getOrgColor("Acme Corp")
80+
expect(ORG_AVATAR_COLORS).toContain(color)
81+
})
82+
83+
test("deterministic: same name always same color", () => {
84+
expect(getOrgColor("Test Org")).toBe(getOrgColor("Test Org"))
85+
})
86+
})
87+
88+
// xss gate for org logo urls. org admins can set whatever logo url they want,
89+
// so we gotta make sure nobody sneaks in a `javascript:` or `vbscript:` url
90+
// that would execute when we stick it in an `<img>` tag or anywhere else in
91+
// the dom. also blocks `data:` urls that aren't actual images since those
92+
// can carry scripts too.
93+
describe("sanitizeLogoUrl", () => {
94+
test("allows normal https URLs through", () => {
95+
expect(sanitizeLogoUrl("https://example.com/logo.png")).toBe(
96+
"https://example.com/logo.png"
97+
)
98+
})
99+
100+
test("allows http URLs through", () => {
101+
expect(sanitizeLogoUrl("http://example.com/logo.png")).toBe(
102+
"http://example.com/logo.png"
103+
)
104+
})
105+
106+
test("allows data:image URLs for file previews", () => {
107+
const dataUrl = "data:image/png;base64,iVBORw0KGgo="
108+
expect(sanitizeLogoUrl(dataUrl)).toBe(dataUrl)
109+
})
110+
111+
test("allows blob URLs for local file objects", () => {
112+
const blobUrl = "blob:http://localhost:3000/some-uuid"
113+
expect(sanitizeLogoUrl(blobUrl)).toBe(blobUrl)
114+
})
115+
116+
test("blocks javascript: protocol", () => {
117+
expect(sanitizeLogoUrl("javascript:alert(1)")).toBe("")
118+
})
119+
120+
test("blocks vbscript: protocol", () => {
121+
expect(sanitizeLogoUrl("vbscript:MsgBox('hi')")).toBe("")
122+
})
123+
124+
test("blocks data: URLs that aren't images", () => {
125+
expect(sanitizeLogoUrl("data:text/html,<script>alert(1)</script>")).toBe("")
126+
})
127+
128+
test("returns empty string for null/undefined", () => {
129+
expect(sanitizeLogoUrl(null)).toBe("")
130+
expect(sanitizeLogoUrl(undefined)).toBe("")
131+
})
132+
133+
test("returns empty string for empty/whitespace strings", () => {
134+
expect(sanitizeLogoUrl("")).toBe("")
135+
expect(sanitizeLogoUrl(" ")).toBe("")
136+
})
137+
138+
test("passes through relative URLs", () => {
139+
expect(sanitizeLogoUrl("/logos/acme.png")).toBe("/logos/acme.png")
140+
})
141+
142+
test("trims whitespace before checking", () => {
143+
expect(sanitizeLogoUrl(" https://example.com/logo.png ")).toBe(
144+
"https://example.com/logo.png"
145+
)
146+
})
147+
})

packages/hoppscotch-common/src/platform/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export type PlatformDef = {
3535
history: HistoryPlatformDef
3636
}
3737
kernelInterceptors: KernelInterceptorsPlatformDef
38-
instance?: InstancePlatformDef
3938
additionalInspectors?: InspectorsPlatformDef
4039
spotlight?: SpotlightPlatformDef
4140
platformFeatureFlags: {

packages/hoppscotch-common/src/platform/instance.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,6 @@ export type InstancePlatformDef = {
4646
*/
4747
customInstanceSwitcherComponent?: Component
4848

49-
/**
50-
* Component to render additional entries before the instances list.
51-
* Desktop injects the "Hoppscotch Cloud" entry here, which resolves
52-
* to the user's last-used org.
53-
*/
54-
additionalEntriesComponent?: Component
55-
5649
/**
5750
* Returns an observable stream of the current connection state
5851
*/

0 commit comments

Comments
 (0)