feat(common): add platform support for organization switcher (#5708)

Extends the organization platform definition to support switching between multiple organizations and displaying custom branding (logo and name) in the application header. Adds shared utilities for file uploads and avatar generation, including deterministic colour support.

These changes enable the Cloud for Organizations tier to offer:
- Multi-organization switching via sidebar UI.
- Custom logo uploads for organization branding.
- Seamless navigation between different organization instances.
This commit is contained in:
James George 2025-12-19 12:39:50 +05:30 committed by GitHub
parent 824dce79d0
commit 3d8810ec74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 479 additions and 8 deletions

View file

@ -152,6 +152,7 @@
"new_version_found": "New version found. Refresh to update.",
"open_in_hoppscotch": "Open in Hoppscotch",
"options": "Options",
"powered_by": "Powered by Hoppscotch",
"proxy_privacy_policy": "Proxy privacy policy",
"reload": "Reload",
"search": "Search and commands",
@ -735,6 +736,22 @@
"title": "Export",
"success": "Successfully exported"
},
"file_upload": {
"choose_file": "Choose file",
"max_size_format": "Max 5MB. Supports JPEG, PNG, GIF, WebP",
"profile_photo_updated": "Profile photo updated successfully",
"profile_photo_removed": "Profile photo removed successfully",
"org_logo_updated": "Organization logo updated successfully",
"error_size_limit": "File size must be less than 5MB",
"error_invalid_format": "File must be an image (JPEG, PNG, GIF, or WebP)",
"error_invalid_upload_type": "Invalid upload type",
"error_invalid_org_id": "Invalid organization ID format",
"error_upload_failed": "Upload failed. Please try again",
"error_network_failed": "Network error. Please check your connection",
"error_timeout": "Upload timed out after 30 seconds. Please try again",
"error_missing_backend_url": "Backend URL is not configured. Please set the VITE_BACKEND_API_URL environment variable in your environment settings",
"error_invalid_backend_url": "Invalid backend URL configuration"
},
"filename": {
"cookie_key_value_pairs": "Cookie",
"codegen": "{request_name} - code",
@ -1257,6 +1274,7 @@
"profile_description": "Update your profile details",
"profile_email": "Email address",
"profile_name": "Profile name",
"profile_photo": "Profile photo",
"proxy": "Proxy",
"proxy_url": "Proxy URL",
"proxy_use_toggle": "Use the proxy middleware to send requests",
@ -2191,7 +2209,25 @@
"delete_account_description": "This will delete all data associated with your Hoppscotch account, including this and any other organizations you are part of.",
"delete_account": "Delete Hoppscotch Account",
"user_deletion_failed_sole_admin": "The user is the sole admin of one or more organization instances. Please demote the user before attempting deletion.",
"user_deletion_failed_sole_team_owner": "The user is the sole team owner on one or more organization instances. Please transfer the ownership or delete the relevant workspaces before attempting deletion."
"user_deletion_failed_sole_team_owner": "The user is the sole team owner on one or more organization instances. Please transfer the ownership or delete the relevant workspaces before attempting deletion.",
"no_organizations": "You are not a member of any organizations",
"admin": "Admin"
},
"organization_sidebar": {
"instances": "Instances",
"hoppscotch": "Hoppscotch",
"organizations": "Organizations",
"admin": "Admin",
"no_orgs": "No organizations yet",
"no_orgs_title": "No organizations yet",
"no_orgs_description": "Join or create an organization to collaborate with your team",
"error_loading": "Failed to load organizations",
"expand": "Expand sidebar",
"collapse": "Collapse sidebar",
"status_canceled": "Canceled",
"status_inactive": "Inactive",
"status_canceled_tooltip": "Subscription canceled - Contact support to reactivate this workspace",
"status_inactive_tooltip": "This organization is temporarily inactive - Contact support for assistance"
},
"billing": {
"confirm": {

View file

@ -235,6 +235,7 @@ declare module 'vue' {
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheck: typeof import('~icons/lucide/check')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
@ -251,11 +252,13 @@ declare module 'vue' {
IconLucideLoader2: typeof import('~icons/lucide/loader2')['default']
IconLucideLock: typeof import('~icons/lucide/lock')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucidePauseCircle: typeof import('~icons/lucide/pause-circle')['default']
IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideTerminal: typeof import('~icons/lucide/terminal')['default']
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']

View file

@ -14,6 +14,7 @@
}"
>
<div class="flex">
<!-- Instance Switcher (Desktop/On-prem) -->
<tippy
v-if="platform.instance?.instanceSwitchingEnabled"
interactive
@ -43,6 +44,42 @@
</div>
</template>
</tippy>
<router-link
v-else-if="showOrgLogo"
to="/"
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-primaryDark focus-visible:bg-primaryDark focus-visible:outline-none transition-colors"
>
<div
v-if="orgInfo?.logo"
class="h-6 w-6 rounded overflow-hidden flex-shrink-0"
>
<img
:src="sanitizeLogoUrl(orgInfo.logo)"
:alt="orgInfo.name || t('app.name')"
class="h-full w-full object-cover"
/>
</div>
<div
v-else-if="orgInfo?.name"
class="h-6 w-6 rounded flex items-center justify-center text-xs font-semibold flex-shrink-0"
:class="getOrgColor(orgInfo.name)"
aria-hidden="true"
>
{{ getOrgInitials(orgInfo.name) }}
</div>
<div class="flex items-center gap-1.5 min-w-0">
<span class="font-bold tracking-wide text-secondaryDark truncate">
{{ orgInfo?.name || t("app.name") }}
</span>
<span
class="text-secondary text-xs hidden sm:inline flex-shrink-0"
>
{{ t("app.powered_by") }}
</span>
</div>
</router-link>
<HoppButtonSecondary
v-else
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
@ -354,6 +391,11 @@ import { getKernelMode } from "@hoppscotch/kernel"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { defineActionHandler, invokeAction } from "@helpers/actions"
import {
getOrgInitials,
getOrgColor,
sanitizeLogoUrl,
} from "@helpers/utils/organization"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { useService } from "dioc/vue"
import * as TE from "fp-ts/TaskEither"
@ -390,6 +432,7 @@ const instanceSwitcherRef =
kernelMode === "desktop" ? ref<any | null>(null) : ref(null)
const isUserAdmin = ref(false)
const orgInfo = ref<{ name?: string; logo?: string | null } | null>(null)
/**
* Feature flag to enable the workspace selector login conversion
@ -398,18 +441,23 @@ const workspaceSelectorFlagEnabled = computed(
() => !!platform.platformFeatureFlags.workspaceSwitcherLogin?.value
)
/**
* Show the dashboard link if the user is not on the default cloud instance and is an admin
*/
const showOrgLogo = computed(() => {
return platform.organization?.isDefaultCloudInstance === false
})
onMounted(async () => {
const { organization } = platform
if (!organization || organization.isDefaultCloudInstance) return
const orgInfo = await organization.getOrgInfo()
const fetchedOrgInfo = await organization.getOrgInfo()
if (orgInfo) {
isUserAdmin.value = !!orgInfo.isAdmin
if (fetchedOrgInfo) {
isUserAdmin.value = !!fetchedOrgInfo.isAdmin
orgInfo.value = {
name: fetchedOrgInfo.name,
logo: fetchedOrgInfo.logo || null,
}
}
})

View file

@ -0,0 +1,249 @@
import { ref } from "vue"
import * as E from "fp-ts/Either"
import { useToast } from "./toast"
import { useI18n } from "./i18n"
export type UploadType = "org-logo"
export interface UploadResult {
success: boolean
url?: string
message?: string
statusCode?: number
}
export interface UploadError {
message: string
statusCode?: number
}
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const UPLOAD_TIMEOUT_MS = 30000 // 30 seconds
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
/**
* Validates an image file before upload
* @param file The file to validate
* @returns null if valid, error message string if invalid
*/
export function validateImageFile(file: File): string | null {
// Check file size
if (file.size > MAX_FILE_SIZE) {
return "file_upload.error_size_limit"
}
// Check file type
if (!ALLOWED_TYPES.includes(file.type)) {
return "file_upload.error_invalid_format"
}
return null
}
/**
* Creates a preview URL for an image file
* @param file The file to preview
* @returns Preview URL (remember to revoke with URL.revokeObjectURL when done)
*/
export function createImagePreview(file: File): string {
return URL.createObjectURL(file)
}
/**
* Composable for handling file uploads with validation and error handling
*/
export function useFileUpload() {
const toast = useToast()
const t = useI18n()
const uploading = ref(false)
const previewUrl = ref<string | null>(null)
/**
* Uploads a file to the backend (organization logos only)
* @param file The file to upload
* @param uploadType Type of upload (org-logo)
* @param orgId Organization ID for org logo uploads
* @param getAuthConfig Function to get axios auth configuration
* @returns Either error or upload result
*/
const uploadFile = async (
file: File,
uploadType: UploadType,
orgId: string | null,
getAuthConfig: () => Promise<{
headers: Record<string, string>
withCredentials?: boolean
}>
): Promise<E.Either<UploadError, UploadResult>> => {
// Validate file
const validationError = validateImageFile(file)
if (validationError) {
return E.left({
message: validationError,
})
}
// Validate backend URL early to fail fast
const backendUrl = import.meta.env.VITE_BACKEND_API_URL
if (!backendUrl) {
return E.left({
message: "file_upload.error_missing_backend_url",
})
}
// Validate backend URL format to prevent SSRF attacks
try {
const url = new URL(backendUrl)
// Ensure it's HTTP/HTTPS protocol
if (!["http:", "https:"].includes(url.protocol)) {
console.error(
"Invalid backend URL protocol (expected http/https):",
url.protocol
)
return E.left({
message: "file_upload.error_invalid_backend_url",
})
}
} catch (error) {
// Don't expose the malformed URL in error messages for security
console.error("Invalid backend URL format:", error)
return E.left({
message: "file_upload.error_invalid_backend_url",
})
}
uploading.value = true
try {
const formData = new FormData()
formData.append("file", file)
// Get auth configuration
const authConfig = await getAuthConfig()
let endpoint: string
if (uploadType === "org-logo" && orgId) {
// Validate orgId to prevent path traversal attacks
// Organization IDs are derived from domains and must be lowercase alphanumeric with hyphens
if (!/^[a-z0-9-]+$/.test(orgId)) {
console.error("Invalid organization ID format:", orgId)
return E.left({
message: "file_upload.error_invalid_org_id",
})
}
endpoint = `${backendUrl}/upload/organization/${orgId}/logo`
} else {
return E.left({
message: "file_upload.error_invalid_upload_type",
})
}
// Make upload request with timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS)
let response: Response
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
...authConfig.headers,
},
body: formData,
credentials: authConfig.withCredentials ? "include" : "same-origin",
signal: controller.signal,
})
} catch (error: unknown) {
// Fetch throws DOMException with name "AbortError" when aborted (e.g., timeout via AbortController).
// Browsers may also throw a TypeError for network failures, but we handle those generically.
// See: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#exceptions
if (error instanceof DOMException && error.name === "AbortError") {
return E.left({
message: "file_upload.error_timeout",
})
}
// Handle network errors or other fetch failures
console.error("Upload fetch error:", error)
return E.left({
message: "file_upload.error_network_failed",
})
} finally {
clearTimeout(timeoutId)
}
let result: UploadResult
try {
result = await response.json()
} catch (error: unknown) {
console.error("Failed to parse upload response:", error)
return E.left({
message: "file_upload.error_upload_failed",
statusCode: response.status,
})
}
if (!response.ok || !result.success) {
return E.left({
message: result.message || "file_upload.error_upload_failed",
statusCode: result.statusCode || response.status,
})
}
return E.right(result)
} finally {
uploading.value = false
}
}
/**
* Handles file selection with preview
* @param event File input change event
* @returns Selected file or null
*/
const handleFileSelect = (
event: Event
): { file: File; preview: string } | null => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return null
// Validate file
const validationError = validateImageFile(file)
if (validationError) {
toast.error(t(validationError))
return null
}
// Create preview
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = createImagePreview(file)
return {
file,
preview: previewUrl.value,
}
}
/**
* Cleans up preview URL
*/
const cleanupPreview = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
}
return {
uploading,
previewUrl,
uploadFile,
handleFileSelect,
cleanupPreview,
validateImageFile,
createImagePreview,
}
}

View file

@ -0,0 +1,96 @@
/**
* Extract initials from organization name (1-2 characters)
* Trims whitespace and handles edge cases like special characters
*/
export const getOrgInitials = (name: string): string => {
const trimmedName = name.trim()
// Return "?" for empty or whitespace-only strings
if (!trimmedName) return "?"
const initials = trimmedName
.split(" ")
.filter((word) => word !== "")
.map((word) => word[0])
.filter((char) => char !== undefined)
.join("")
.toUpperCase()
.slice(0, 2)
return initials || "?"
}
/**
* Generate deterministic color index from organization name hash
*/
export const getOrgColorIndex = (orgName: string): number => {
const normalized = orgName.toLowerCase().trim()
let hash = normalized.length
for (let i = 0; i < normalized.length; i++) {
hash = (hash << 5) - hash + normalized.charCodeAt(i)
hash = hash | 0
}
return Math.abs(hash)
}
export const ORG_AVATAR_COLORS = [
"bg-blue-500 text-white",
"bg-green-500 text-white",
"bg-purple-500 text-white",
"bg-pink-500 text-white",
"bg-orange-500 text-white",
"bg-teal-500 text-white",
"bg-indigo-500 text-white",
"bg-red-500 text-white",
"bg-yellow-500 text-gray-900",
"bg-cyan-500 text-white",
]
/**
* Get deterministic color for organization avatar
*/
export const getOrgColor = (orgName: string): string => {
const index = getOrgColorIndex(orgName)
return ORG_AVATAR_COLORS[index % ORG_AVATAR_COLORS.length]
}
/**
* Sanitize URL to prevent XSS attacks
* Only allows http/https/data/blob protocols
* @param url - The URL to sanitize
* @returns The sanitized URL or empty string if invalid
*/
export const sanitizeLogoUrl = (url: string | null | undefined): string => {
if (!url) return ""
const trimmed = url.trim()
if (!trimmed) return ""
// Allow data URLs for file previews
if (trimmed.startsWith("data:image/")) {
return trimmed
}
// Allow blob URLs for local file objects
if (trimmed.startsWith("blob:")) {
return trimmed
}
// Extract protocol from URL
const match = trimmed.match(/^([a-z0-9+.-]+):/i)
if (!match) {
// No protocol, assume relative URL is safe
return trimmed
}
const protocol = match[1].toLowerCase()
// Only allow safe protocols
if (protocol === "http" || protocol === "https") {
return trimmed
}
// Reject dangerous protocols like javascript:, vbscript:, etc.
console.warn(`Blocked potentially unsafe logo URL protocol: ${protocol}`)
return ""
}

View file

@ -16,7 +16,17 @@
>
<AppSidenav />
</Pane>
<Pane class="flex flex-1 !overflow-hidden">
<Pane
v-if="showOrgSidebar"
style="width: auto; height: auto"
class="hidden !overflow-auto md:flex md:flex-col"
>
<component
:is="platform.organization.customOrganizationSidebarComponent"
/>
</Pane>
<!-- Changed to !overflow-auto to allow organization sidebar and main content to scroll independently -->
<Pane class="flex flex-1 !overflow-auto">
<Splitpanes
class="no-splitter"
:dbl-click-splitter="false"
@ -111,6 +121,14 @@ const rootExtensionComponents = uiExtensionService.rootUIExtensionComponents
const HAS_OPENED_SPOTLIGHT = useSetting("HAS_OPENED_SPOTLIGHT")
// Show organization sidebar if organization switching is enabled and sidebar component is provided
const showOrgSidebar = computed(() => {
return (
platform.organization?.organizationSwitchingEnabled === true &&
platform.organization.customOrganizationSidebarComponent
)
})
onBeforeMount(() => {
if (!mdAndLarger.value) {
rightSidebar.value = false

View file

@ -1,10 +1,31 @@
import type { Component } from "vue"
export type OrganizationPlatformDef = {
isDefaultCloudInstance: boolean
getOrgInfo: () => Promise<{
orgID: string
orgDomain: string
name?: string
logo?: string | null
isAdmin: boolean
} | null>
getRootDomain: () => string
initiateOnboarding: () => void
/**
* Whether organization switching is enabled for this platform
* If true, an organization switcher will be shown
*/
organizationSwitchingEnabled?: boolean
/**
* Custom component for the organization sidebar
* If provided, will be shown as a sidebar in the layout
*/
customOrganizationSidebarComponent?: Component
/**
* Switch to a specific organization instance or default cloud instance
* For web: redirects to the target URL
* For desktop: downloads the organization bundle and connects
* @param orgDomain - The organization domain (null for default cloud instance)
*/
switchToInstance?: (orgDomain: string | null) => void | Promise<void>
}