From 3d8810ec74e1204a6ff246934ca70ec8d50dee60 Mon Sep 17 00:00:00 2001 From: James George <25279263+jamesgeorge007@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:39:50 +0530 Subject: [PATCH] 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. --- packages/hoppscotch-common/locales/en.json | 38 ++- .../hoppscotch-common/src/components.d.ts | 3 + .../src/components/app/Header.vue | 60 ++++- .../src/composables/useFileUpload.ts | 249 ++++++++++++++++++ .../src/helpers/utils/organization.ts | 96 +++++++ .../hoppscotch-common/src/layouts/default.vue | 20 +- .../src/platform/organization.ts | 21 ++ 7 files changed, 479 insertions(+), 8 deletions(-) create mode 100644 packages/hoppscotch-common/src/composables/useFileUpload.ts create mode 100644 packages/hoppscotch-common/src/helpers/utils/organization.ts diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 89c39870..3c0bbe8d 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -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": { diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index d0329b81..b6b19406 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -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'] diff --git a/packages/hoppscotch-common/src/components/app/Header.vue b/packages/hoppscotch-common/src/components/app/Header.vue index a1766f20..7a358d57 100644 --- a/packages/hoppscotch-common/src/components/app/Header.vue +++ b/packages/hoppscotch-common/src/components/app/Header.vue @@ -14,6 +14,7 @@ }" >
+ + + +
+ +
+ +
+ + {{ orgInfo?.name || t("app.name") }} + + +
+
+ (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, + } } }) diff --git a/packages/hoppscotch-common/src/composables/useFileUpload.ts b/packages/hoppscotch-common/src/composables/useFileUpload.ts new file mode 100644 index 00000000..ac972cfe --- /dev/null +++ b/packages/hoppscotch-common/src/composables/useFileUpload.ts @@ -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(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 + withCredentials?: boolean + }> + ): Promise> => { + // 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, + } +} diff --git a/packages/hoppscotch-common/src/helpers/utils/organization.ts b/packages/hoppscotch-common/src/helpers/utils/organization.ts new file mode 100644 index 00000000..e1f219ed --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/organization.ts @@ -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 "" +} diff --git a/packages/hoppscotch-common/src/layouts/default.vue b/packages/hoppscotch-common/src/layouts/default.vue index 43d0f943..523aa6d0 100644 --- a/packages/hoppscotch-common/src/layouts/default.vue +++ b/packages/hoppscotch-common/src/layouts/default.vue @@ -16,7 +16,17 @@ > - + + + { + return ( + platform.organization?.organizationSwitchingEnabled === true && + platform.organization.customOrganizationSidebarComponent + ) +}) + onBeforeMount(() => { if (!mdAndLarger.value) { rightSidebar.value = false diff --git a/packages/hoppscotch-common/src/platform/organization.ts b/packages/hoppscotch-common/src/platform/organization.ts index aa39db23..b0706a72 100644 --- a/packages/hoppscotch-common/src/platform/organization.ts +++ b/packages/hoppscotch-common/src/platform/organization.ts @@ -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 }