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 }