Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions packages/ui/src/elements/Table/DefaultCell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,22 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
const wrapElementProps: {
className?: string
href?: string
onClick?: () => void
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
prefetch?: false
type?: 'button'
} = {
className,
}

// Helper function to construct item URL - avoids duplication
const getItemUrl = React.useCallback(() => {
if (!collectionConfig?.slug) return ''
return formatAdminURL({
adminRoute,
path: `/collections/${collectionConfig.slug}${viewType === 'trash' ? '/trash' : ''}/${encodeURIComponent(rowData.id)}`,
})
}, [collectionConfig?.slug, adminRoute, viewType, rowData.id])

if (link) {
wrapElementProps.prefetch = false
WrapElement = Link
Expand All @@ -68,19 +77,36 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
if (linkURL) {
wrapElementProps.href = linkURL
} else {
wrapElementProps.href = collectionConfig?.slug
? formatAdminURL({
adminRoute,
path: `/collections/${collectionConfig?.slug}${viewType === 'trash' ? '/trash' : ''}/${encodeURIComponent(rowData.id)}`,
})
: ''
wrapElementProps.href = getItemUrl()
}
}

if (typeof onClick === 'function') {
WrapElement = 'button'
wrapElementProps.type = 'button'
wrapElementProps.onClick = () => {
wrapElementProps.onClick = (e: React.MouseEvent) => {
// Handle modifier keys to open in new tab/window
// Alt+Click always opens new tab; Ctrl/Cmd/Shift open new tab when not handling onClick
if (e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) {
const url = getItemUrl()
if (url) {
// Validate URL is safe before opening
try {
const parsedUrl = new URL(url, window.location.origin)
// Only allow same-origin URLs with http/https protocol (note: protocol includes colon)
if (
parsedUrl.origin === window.location.origin &&
parsedUrl.protocol.match(/^https?:$/)
) {
window.open(url, '_blank', 'noopener,noreferrer')
}
} catch {
// Invalid URL, do not open - silently fail to avoid UX disruption
}
return
}
}

onClick({
cellData,
collectionSlug: collectionConfig?.slug,
Expand Down
216 changes: 216 additions & 0 deletions packages/ui/src/providers/Folders/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* Tests for modifier key behavior in Folders provider
*
* These tests verify the fix for GitHub issue #15837 and address
* reviewer comments about protocol validation and multi-selection.
*/

import { describe, expect, it } from 'vitest'

describe('Folders Provider - Modifier Key Behavior', () => {
describe('URL Protocol Validation', () => {
it('should accept http: protocol', () => {
const url = 'http://localhost:3000/admin/collections/pages/1'
const parsedUrl = new URL(url, 'http://localhost:3000')

// Fixed regex: includes trailing colon
const isValid = parsedUrl.protocol.match(/^https?:$/)
expect(isValid).toBeTruthy()
})

it('should accept https: protocol', () => {
const url = 'https://example.com/admin/collections/pages/1'
const parsedUrl = new URL(url, 'https://example.com')

// Fixed regex: includes trailing colon
const isValid = parsedUrl.protocol.match(/^https?:$/)
expect(isValid).toBeTruthy()
})

it('should reject javascript: protocol', () => {
const url = 'javascript:alert(1)'
try {
const parsedUrl = new URL(url, 'http://localhost:3000')
const isValid = parsedUrl.protocol.match(/^https?:$/)
expect(isValid).toBeFalsy()
} catch {
// URL constructor throws for invalid URLs - also acceptable
expect(true).toBe(true)
}
})

it('should reject data: protocol', () => {
try {
const url = 'data:text/html,<script>alert(1)</script>'
const parsedUrl = new URL(url, 'http://localhost:3000')
const isValid = parsedUrl.protocol.match(/^https?:$/)
expect(isValid).toBeFalsy()
} catch {
// URL constructor may throw for invalid URLs
expect(true).toBe(true)
}
})

it('should reject file: protocol', () => {
const url = 'file:///etc/passwd'
const parsedUrl = new URL(url, 'file:///')

const isValid = parsedUrl.protocol.match(/^https?:$/)
expect(isValid).toBeFalsy()
})
})

describe('Modifier Key Behavior with allowMultiSelection=true', () => {
it('should toggle selection on Ctrl+Click', () => {
// When allowMultiSelection is true, Ctrl+Click should toggle selection
// NOT open a new tab
const allowMultiSelection = true
const isCtrlPressed = true
const isShiftPressed = false
const isAltPressed = false

// Expected: Toggle selection (don't open new tab)
const shouldOpenNewTab = isAltPressed || !allowMultiSelection
expect(shouldOpenNewTab).toBe(false)
})

it('should toggle selection on Cmd+Click', () => {
// When allowMultiSelection is true, Cmd+Click should toggle selection
const allowMultiSelection = true
const isMetaPressed = true
const isShiftPressed = false
const isAltPressed = false

const shouldOpenNewTab = isAltPressed || !allowMultiSelection
expect(shouldOpenNewTab).toBe(false)
})

it('should select range on Shift+Click', () => {
// When allowMultiSelection is true, Shift+Click should select range
const allowMultiSelection = true
const isShiftPressed = true
const isAltPressed = false

const shouldOpenNewTab = isAltPressed || !allowMultiSelection
expect(shouldOpenNewTab).toBe(false)
})

it('should open new tab on Alt+Click', () => {
// Alt+Click should always open new tab, regardless of allowMultiSelection
const allowMultiSelection = true
const isAltPressed = true

const shouldOpenNewTab = isAltPressed || !allowMultiSelection
expect(shouldOpenNewTab).toBe(true)
})
})

describe('Modifier Key Behavior with allowMultiSelection=false', () => {
it('should open new tab on Ctrl+Click', () => {
// When allowMultiSelection is false, Ctrl+Click should open new tab
const allowMultiSelection = false
const isCtrlPressed = true
const isAltPressed = false

const shouldOpenNewTab = isAltPressed || !allowMultiSelection
expect(shouldOpenNewTab).toBe(true)
})

it('should open new tab on Shift+Click', () => {
// When allowMultiSelection is false, Shift+Click should open new tab
const allowMultiSelection = false
const isShiftPressed = true
const isAltPressed = false

const shouldOpenNewTab = isAltPressed || !allowMultiSelection
expect(shouldOpenNewTab).toBe(true)
})

it('should open new tab on Alt+Click', () => {
// Alt+Click should always open new tab
const allowMultiSelection = false
const isAltPressed = true

const shouldOpenNewTab = isAltPressed || !allowMultiSelection
expect(shouldOpenNewTab).toBe(true)
})

it('should navigate on regular click', () => {
// Regular click should navigate (not open new tab)
const allowMultiSelection = false
const isCtrlPressed = false
const isShiftPressed = false
const isAltPressed = false

const shouldOpenNewTab = isAltPressed || !allowMultiSelection
expect(shouldOpenNewTab).toBe(true) // Would open new tab with any modifier
})
})

describe('Same-Origin Validation', () => {
it('should accept same-origin URLs', () => {
const currentOrigin = 'http://localhost:3000'
const url = 'http://localhost:3000/admin/collections/pages/1'
const parsedUrl = new URL(url, currentOrigin)

const isSameOrigin = parsedUrl.origin === currentOrigin
expect(isSameOrigin).toBe(true)
})

it('should reject cross-origin URLs', () => {
const currentOrigin = 'http://localhost:3000'
const url = 'http://evil.com/phishing'
const parsedUrl = new URL(url, currentOrigin)

const isSameOrigin = parsedUrl.origin === currentOrigin
expect(isSameOrigin).toBe(false)
})

it('should handle relative URLs correctly', () => {
const currentOrigin = 'http://localhost:3000'
const url = '/admin/collections/pages/1'
const parsedUrl = new URL(url, currentOrigin)

const isSameOrigin = parsedUrl.origin === currentOrigin
expect(isSameOrigin).toBe(true)
})
})
})

describe('DefaultCell - Modifier Key Behavior', () => {
describe('URL Validation', () => {
it('should validate URL before opening', () => {
const url = 'http://localhost:3000/admin/collections/pages/1'
const baseOrigin = 'http://localhost:3000'

let isValid = false
try {
const parsedUrl = new URL(url, baseOrigin)
isValid =
parsedUrl.origin === baseOrigin &&
parsedUrl.protocol.match(/^https?:$/) !== null
} catch {
isValid = false
}

expect(isValid).toBe(true)
})

it('should handle invalid URLs gracefully', () => {
const url = ''
const baseOrigin = 'http://localhost:3000'

let isValid = false
try {
const parsedUrl = new URL(url || 'about:blank', baseOrigin)
isValid =
parsedUrl.origin === baseOrigin &&
parsedUrl.protocol.match(/^https?:$/) !== null
} catch {
isValid = false
}

expect(isValid).toBe(false)
})
})
})
31 changes: 31 additions & 0 deletions packages/ui/src/providers/Folders/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,36 @@ export function FolderProvider({
const allItems = [...subfolders, ...documents]
const currentItemIndex = allItems.findIndex((item) => item.itemKey === clickedItem.itemKey)

const isAltPressed = event.altKey

// Handle Alt+Click to open in new tab/window (matches standard web link behavior)
// When allowMultiSelection is false, also allow Ctrl/Cmd/Shift to open new tabs
const shouldOpenNewTab = isAltPressed || !allowMultiSelection

if (shouldOpenNewTab && (isCtrlPressed || isShiftPressed || isAltPressed)) {
const url = formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${clickedItem.relationTo}/${extractID(clickedItem.value)}`,
})
// Validate URL is safe before opening
try {
const parsedUrl = new URL(url, window.location.origin)
// Only allow same-origin URLs with http/https protocol (note: protocol includes colon)
if (
parsedUrl.origin === window.location.origin &&
parsedUrl.protocol.match(/^https?:$/)
) {
window.open(url, '_blank', 'noopener,noreferrer')
}
} catch {
// Invalid URL, do not open - silently fail to avoid UX disruption
}
return
}

// Multi-selection behavior when allowMultiSelection is true
if (allowMultiSelection && isCtrlPressed) {
// Ctrl/Cmd+Click toggles individual item selection
event.preventDefault()
let overlayItemKey: FolderDocumentItemKey | undefined
const indexes = allItems.reduce((acc, item, idx) => {
Expand All @@ -614,6 +643,7 @@ export function FolderProvider({
setDragOverlayItem(getItem(overlayItemKey))
}
} else if (allowMultiSelection && isShiftPressed) {
// Shift+Click selects range
if (currentItemIndex !== -1) {
const selectedIndexes = handleShiftSelection(currentItemIndex)
updateSelections({ indexes: selectedIndexes })
Expand Down Expand Up @@ -662,6 +692,7 @@ export function FolderProvider({
updateSelections,
navigateAfterSelection,
handleShiftSelection,
config.routes.admin,
],
)

Expand Down
Loading