feat(desktop): cloud for orgs platform contract (#5903)
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
5ae9639901
commit
02b3dbcf5c
13 changed files with 339 additions and 64 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -35,7 +35,6 @@ export type PlatformDef = {
|
|||
history: HistoryPlatformDef
|
||||
}
|
||||
kernelInterceptors: KernelInterceptorsPlatformDef
|
||||
instance?: InstancePlatformDef
|
||||
additionalInspectors?: InspectorsPlatformDef
|
||||
spotlight?: SpotlightPlatformDef
|
||||
platformFeatureFlags: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
44
packages/hoppscotch-desktop/src-tauri/capabilities/README.md
Normal file
44
packages/hoppscotch-desktop/src-tauri/capabilities/README.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue