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 @@
}"
>
+
+
+
+
+
![]()
+
+
+ {{ getOrgInitials(orgInfo.name) }}
+
+
+
+ {{ orgInfo?.name || t("app.name") }}
+
+
+ • {{ t("app.powered_by") }}
+
+
+
+
(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
}