improvement(files): improve file sharing UI (#6983)

This commit is contained in:
Justin Blumencranz
2026-08-22 14:08:29 -07:00
committed by GitHub
parent 8c7a2f1df0
commit fb8f0d66d1
6 changed files with 887 additions and 127 deletions
@@ -15,7 +15,7 @@ import {
FolderInput,
Pencil,
} from '@sim/emcn'
import { Download, Link, Pin, Trash } from '@sim/emcn/icons'
import { Download, Link, Pin, Send, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders'
@@ -24,6 +24,7 @@ interface FileRowContextMenuProps {
position: { x: number; y: number }
onClose: () => void
onOpen: () => void
onCopyLink?: () => void
onDownload?: () => void
onRename: () => void
onDelete: () => void
@@ -42,6 +43,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
position,
onClose,
onOpen,
onCopyLink,
onDownload,
onRename,
onDelete,
@@ -87,6 +89,12 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
Open
</DropdownMenuItem>
)}
{!isMultiSelect && onCopyLink && (
<DropdownMenuItem onSelect={onCopyLink}>
<Link />
Copy Link
</DropdownMenuItem>
)}
{onDownload && (
<DropdownMenuItem onSelect={onDownload}>
<Download />
@@ -109,7 +117,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
)}
{!isMultiSelect && onShare && (
<DropdownMenuItem onSelect={onShare}>
<Link />
<Send />
Share
</DropdownMenuItem>
)}
@@ -0,0 +1,678 @@
/**
* @vitest-environment jsdom
*/
import {
act,
Children,
type ComponentType,
cloneElement,
isValidElement,
type ReactElement,
type ReactNode,
} from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
import type {
ShareAuthType,
ShareRecord,
UpsertFileShareBody,
} from '@/lib/api/contracts/public-shares'
interface MockMutationVariables extends UpsertFileShareBody {
workspaceId: string
fileId: string
}
interface MockMutationCallbacks {
onSuccess?: () => void
}
interface MockButtonGroupItemProps {
value: string
children: ReactNode
selectedValue?: string
onSelect?: (value: string) => void
disabled?: boolean
}
interface MockFooterAction {
label: ReactNode
onClick: () => void
disabled?: boolean
variant?: 'primary' | 'destructive'
}
type MockFooterSlot = MockFooterAction | { custom: ReactNode }
const {
fileShareQueryState,
fileShareState,
mockCopy,
mockGenerateShortId,
mockMutate,
mockToastSuccess,
mutationState,
permissionConfigState,
} = vi.hoisted(() => ({
fileShareQueryState: { isFetchedAfterMount: true, isError: false },
fileShareState: { current: null as ShareRecord | null },
mockCopy: vi.fn(async () => true),
mockGenerateShortId: vi.fn(() => 'pending-token-1234567890'),
mockMutate: vi.fn(),
mockToastSuccess: vi.fn(),
mutationState: { isPending: false },
permissionConfigState: {
current: {
allowedFileShareAuthTypes: null as ShareAuthType[] | null,
disablePublicFileSharing: false,
},
},
}))
vi.mock('@sim/utils/id', () => ({
generateShortId: mockGenerateShortId,
}))
vi.mock('@sim/emcn/icons', () => ({
Check: () => <svg data-testid='check-icon' />,
Link: () => <svg data-testid='link-icon' />,
Send: () => <svg data-testid='send-icon' />,
}))
vi.mock('@sim/emcn', () => ({
toast: { success: mockToastSuccess },
ButtonGroup: ({
children,
value,
onValueChange,
disabled,
'aria-label': ariaLabel,
}: {
children: ReactNode
value: string
onValueChange: (value: string) => void
disabled?: boolean
'aria-label'?: string
}) => (
<div role='radiogroup' aria-label={ariaLabel}>
{Children.map(children, (child) =>
isValidElement<MockButtonGroupItemProps>(child)
? cloneElement(child as ReactElement<MockButtonGroupItemProps>, {
selectedValue: value,
onSelect: onValueChange,
disabled,
})
: child
)}
</div>
),
ButtonGroupItem: ({
value,
children,
selectedValue,
onSelect,
disabled,
}: MockButtonGroupItemProps) => (
<button
type='button'
role='radio'
aria-checked={selectedValue === value}
disabled={disabled}
onClick={() => !disabled && onSelect?.(value)}
>
{children}
</button>
),
Chip: ({
children,
leftIcon: LeftIcon,
onClick,
disabled,
}: {
children: ReactNode
leftIcon?: ComponentType<{ className?: string }>
onClick?: () => void
disabled?: boolean
}) => (
<button type='button' onClick={onClick} disabled={disabled}>
{LeftIcon ? <LeftIcon /> : null}
{children}
</button>
),
ChipModal: ({
open,
children,
dismissDisabled,
className,
}: {
open: boolean
children: ReactNode
dismissDisabled?: boolean
className?: string
}) =>
open ? (
<div role='dialog' data-dismiss-disabled={dismissDisabled || undefined} className={className}>
{children}
</div>
) : null,
ChipConfirmModal: ({
open,
onOpenChange,
title,
text,
confirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: ReactNode
text?: ReactNode
confirm: MockFooterAction & { pending?: boolean; pendingLabel?: string }
}) =>
open ? (
<section role='alertdialog'>
<h2>{title}</h2>
{text ? <p>{text}</p> : null}
<button type='button' onClick={() => onOpenChange(false)} disabled={confirm.pending}>
Cancel
</button>
<button type='button' onClick={confirm.onClick} disabled={confirm.pending}>
{confirm.pending ? (confirm.pendingLabel ?? confirm.label) : confirm.label}
</button>
</section>
) : null,
ChipModalHeader: ({ children, onClose }: { children: ReactNode; onClose: () => void }) => (
<header>
{children}
<button type='button' onClick={onClose}>
Close
</button>
</header>
),
ChipModalBody: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid='modal-body' className={className}>
{children}
</div>
),
ChipModalField: ({
type,
title,
children,
value,
onChange,
hint,
disabled,
}: {
type: string
title: string
children?: ReactNode
value?: string[]
onChange?: (value: string[]) => void
hint?: ReactNode
disabled?: boolean
}) => (
<section>
<span>{title}</span>
{type === 'emails' ? (
<input
aria-label={title}
value={value?.join(',') ?? ''}
onChange={(event) => onChange?.(event.target.value.split(',').filter(Boolean))}
disabled={disabled}
/>
) : (
children
)}
{hint ? <p>{hint}</p> : null}
</section>
),
ChipModalFooter: ({
onCancel,
primaryAction,
secondaryActions,
}: {
onCancel: () => void
primaryAction: MockFooterAction
secondaryActions?: MockFooterSlot[]
}) => (
<footer>
<div>
{secondaryActions?.map((action, index) =>
'custom' in action ? (
<span key={index}>{action.custom}</span>
) : (
<button key={index} type='button' onClick={action.onClick} disabled={action.disabled}>
{action.label}
</button>
)
)}
</div>
<button type='button' onClick={onCancel}>
Cancel
</button>
<button
type='button'
onClick={primaryAction.onClick}
disabled={primaryAction.disabled}
data-variant={primaryAction.variant ?? 'primary'}
>
{primaryAction.label}
</button>
</footer>
),
useCopyToClipboard: () => ({ copied: false, copy: mockCopy }),
}))
vi.mock('@/components/ui', () => ({
GeneratedPasswordInput: ({
value,
onChange,
placeholder,
disabled,
}: {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
}) => (
<input
aria-label='Password'
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
/>
),
}))
vi.mock('@/lib/core/config/env-flags', () => ({ isSsoEnabled: true }))
vi.mock('@/lib/messaging/email/validation', () => ({
validateAllowlistEntry: () => null,
}))
vi.mock('@/hooks/use-permission-config', () => ({
usePermissionConfig: () => ({
config: permissionConfigState.current,
}),
}))
vi.mock('@/hooks/queries/public-shares', () => ({
useFileShare: () => ({ data: fileShareState.current, ...fileShareQueryState }),
useUpsertFileShare: () => ({
mutate: mockMutate,
isPending: mutationState.isPending,
}),
}))
import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal/share-modal'
const SHARE_URL = 'https://sim.example.com/f/persisted-token'
function createShare(overrides: Partial<ShareRecord> = {}): ShareRecord {
return {
id: 'share-1',
token: 'persisted-token',
url: SHARE_URL,
isActive: true,
resourceType: 'file',
resourceId: 'file-1',
authType: 'public',
hasPassword: false,
allowedEmails: [],
...overrides,
}
}
let container: HTMLDivElement
let onOpenChange: ReturnType<typeof vi.fn<(open: boolean) => void>>
let root: Root
async function renderModal(initialShare: ShareRecord | null = null) {
await act(async () => {
root.render(
<ShareModal
open
onOpenChange={onOpenChange}
workspaceId='workspace-1'
fileId='file-1'
fileName='report.pdf'
initialShare={initialShare}
/>
)
})
}
function button(label: string): HTMLButtonElement {
const match = [...container.querySelectorAll('button')].find(
(candidate) => candidate.textContent === label
)
if (!match) throw new Error(`No button labelled "${label}"`)
return match
}
function queryButton(label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll('button')].find(
(candidate) => candidate.textContent === label
)
}
async function click(label: string) {
await act(async () => button(label).click())
}
async function clickConfirmation(label: string) {
const dialog = container.querySelector<HTMLElement>('[role="alertdialog"]')
const match = [...(dialog?.querySelectorAll('button') ?? [])].find(
(candidate) => candidate.textContent === label
)
if (!match) throw new Error(`No confirmation button labelled "${label}"`)
await act(async () => match.click())
}
async function changePassword(value: string) {
const input = container.querySelector<HTMLInputElement>('[aria-label="Password"]')
if (!input) throw new Error('Password input was not rendered')
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
if (!valueSetter) throw new Error('Password input has no value setter')
await act(async () => {
valueSetter.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
async function changeAllowedEmails(value: string) {
const input = container.querySelector<HTMLInputElement>('[aria-label="Allowed emails"]')
if (!input) throw new Error('Allowed emails input was not rendered')
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
if (!valueSetter) throw new Error('Allowed emails input has no value setter')
await act(async () => {
valueSetter.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
describe('ShareModal', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
onOpenChange = vi.fn()
fileShareState.current = null
fileShareQueryState.isFetchedAfterMount = true
fileShareQueryState.isError = false
mutationState.isPending = false
permissionConfigState.current = {
allowedFileShareAuthTypes: null,
disablePublicFileSharing: false,
}
mockMutate.mockImplementation(
(variables: MockMutationVariables, callbacks?: MockMutationCallbacks) => {
const existing = fileShareState.current
const authType = variables.authType ?? existing?.authType ?? 'public'
fileShareState.current = {
id: existing?.id ?? 'share-1',
token: existing?.token ?? 'persisted-token',
url: existing?.url ?? SHARE_URL,
isActive: variables.isActive,
resourceType: 'file',
resourceId: 'file-1',
authType,
hasPassword: Boolean(variables.password) || existing?.hasPassword === true,
allowedEmails: variables.allowedEmails ?? existing?.allowedEmails ?? [],
}
callbacks?.onSuccess?.()
}
)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
})
it('shares without closing, then exposes the durable link and unshare action', async () => {
await renderModal()
expect(container.querySelector('[data-testid="modal-body"]')).not.toHaveClass('h-[280px]')
expect(container.querySelector('[data-testid="modal-body"]')).not.toHaveClass('flex-none')
expect(button('Public')).toHaveAttribute('aria-checked', 'true')
expect(queryButton('Copy link')).toBeUndefined()
expect(button('Share')).toBeEnabled()
expect(button('Share')).toHaveAttribute('data-variant', 'primary')
await click('Share')
expect(mockMutate).toHaveBeenLastCalledWith(
{
workspaceId: 'workspace-1',
fileId: 'file-1',
token: 'pending-token-1234567890',
isActive: true,
authType: 'public',
},
expect.objectContaining({ onSuccess: expect.any(Function) })
)
expect(onOpenChange).not.toHaveBeenCalled()
expect(mockToastSuccess).toHaveBeenLastCalledWith('File shared')
await renderModal()
expect(button('Unshare')).toBeEnabled()
expect(button('Unshare')).toHaveAttribute('data-variant', 'destructive')
expect(button('Copy link').querySelector('[data-testid="link-icon"]')).not.toBeNull()
await click('Copy link')
expect(mockCopy).toHaveBeenCalledWith(SHARE_URL)
mockMutate.mockClear()
await click('Unshare')
expect(mockMutate).not.toHaveBeenCalled()
expect(button('Unsharing...')).toHaveAttribute('data-variant', 'destructive')
const confirmDialog = container.querySelector<HTMLElement>('[role="alertdialog"]')
expect(confirmDialog).not.toBeNull()
expect(confirmDialog).toHaveTextContent('Unshare file?')
await clickConfirmation('Unshare')
expect(mockMutate).toHaveBeenLastCalledWith(
expect.objectContaining({ isActive: false }),
expect.objectContaining({ onSuccess: expect.any(Function) })
)
expect(onOpenChange).not.toHaveBeenCalled()
expect(mockToastSuccess).toHaveBeenLastCalledWith('File unshared')
await renderModal()
expect(button('Share')).toBeEnabled()
expect(queryButton('Copy link')).toBeUndefined()
})
it('keeps the link visible and changes Unshare to Update while editing the publish mode', async () => {
fileShareState.current = createShare()
await renderModal()
expect(button('Unshare')).toBeEnabled()
await click('Password')
expect(button('Copy link')).toBeEnabled()
expect(button('Update')).toBeDisabled()
expect(button('Update')).toHaveAttribute('data-variant', 'primary')
await changePassword('correct horse battery staple')
expect(button('Update')).toBeEnabled()
await click('Update')
expect(mockMutate).toHaveBeenLastCalledWith(
{
workspaceId: 'workspace-1',
fileId: 'file-1',
token: undefined,
isActive: true,
authType: 'password',
password: 'correct horse battery staple',
},
expect.objectContaining({ onSuccess: expect.any(Function) })
)
expect(onOpenChange).not.toHaveBeenCalled()
expect(mockToastSuccess).toHaveBeenLastCalledWith('Sharing updated')
})
it.each([
{
description: 'null',
initialShare: null,
pendingAction: 'Share',
expectedHint: 'Share to make this file accessible to anyone with the link.',
},
{
description: 'stale',
initialShare: createShare(),
pendingAction: 'Unshare',
expectedHint: 'Anyone with the link can view and download this file.',
},
])(
'waits for the authoritative share read when initial display data is $description',
async ({ initialShare, pendingAction, expectedHint }) => {
fileShareQueryState.isFetchedAfterMount = false
await renderModal(initialShare)
expect(button(pendingAction)).toBeDisabled()
expect(container).toHaveTextContent(expectedHint)
expect(container).not.toHaveTextContent('Loading the current sharing settings...')
fileShareState.current = createShare({
authType: 'password',
hasPassword: true,
})
fileShareQueryState.isFetchedAfterMount = true
await renderModal(initialShare)
expect(button('Password')).toHaveAttribute('aria-checked', 'true')
expect(button('Unshare')).toBeEnabled()
}
)
it.each([
{ mode: 'Email' as const, authType: 'email' as const, entry: 'person@example.com' },
{ mode: 'SSO' as const, authType: 'sso' as const, entry: 'example.com' },
])('requires an allow-list before sharing in $mode mode', async ({ mode, authType, entry }) => {
await renderModal()
await click(mode)
expect(button('Share')).toBeDisabled()
await changeAllowedEmails(entry)
expect(button('Share')).toBeEnabled()
await click('Share')
expect(mockMutate).toHaveBeenLastCalledWith(
{
workspaceId: 'workspace-1',
fileId: 'file-1',
token: 'pending-token-1234567890',
isActive: true,
authType,
allowedEmails: [entry],
},
expect.objectContaining({ onSuccess: expect.any(Function) })
)
expect(onOpenChange).not.toHaveBeenCalled()
})
it.each([
{ mode: 'Password' as const, value: 'correct horse battery staple' },
{ mode: 'Email' as const, value: 'person@example.com' },
])('locks access edits and dismissal while a $mode share is pending', async ({ mode, value }) => {
await renderModal()
await click(mode)
if (mode === 'Password') {
await changePassword(value)
} else {
await changeAllowedEmails(value)
}
let finishMutation: (() => void) | undefined
mockMutate.mockImplementationOnce(
(_variables: MockMutationVariables, callbacks?: MockMutationCallbacks) => {
mutationState.isPending = true
finishMutation = callbacks?.onSuccess
}
)
await click('Share')
await renderModal()
expect(container.querySelector('[role="dialog"]')).toHaveAttribute(
'data-dismiss-disabled',
'true'
)
expect(button('Public')).toBeDisabled()
expect(button('Password')).toBeDisabled()
expect(button('Email')).toBeDisabled()
expect(button('SSO')).toBeDisabled()
expect(button('Sharing...')).toBeDisabled()
const editor = container.querySelector<HTMLInputElement>(
mode === 'Password' ? '[aria-label="Password"]' : '[aria-label="Allowed emails"]'
)
expect(editor).toBeDisabled()
await act(async () => {
mutationState.isPending = false
finishMutation?.()
})
})
it('blocks a new share when public file sharing is disabled', async () => {
permissionConfigState.current = {
allowedFileShareAuthTypes: null,
disablePublicFileSharing: true,
}
await renderModal()
expect(button('Share')).toBeDisabled()
})
it('blocks sharing an inactive saved mode that is no longer allowed', async () => {
permissionConfigState.current = {
allowedFileShareAuthTypes: ['public'],
disablePublicFileSharing: false,
}
fileShareState.current = createShare({
isActive: false,
authType: 'email',
allowedEmails: ['person@example.com'],
})
await renderModal()
expect(button('Email')).toHaveAttribute('aria-checked', 'true')
expect(button('Share')).toBeDisabled()
})
it('allows unsharing an active saved mode that is no longer allowed', async () => {
permissionConfigState.current = {
allowedFileShareAuthTypes: ['public'],
disablePublicFileSharing: false,
}
fileShareState.current = createShare({
authType: 'email',
allowedEmails: ['person@example.com'],
})
await renderModal()
expect(button('Email')).toHaveAttribute('aria-checked', 'true')
expect(button('Unshare')).toBeEnabled()
await click('Unshare')
await clickConfirmation('Unshare')
expect(mockMutate).toHaveBeenLastCalledWith(
expect.objectContaining({ isActive: false }),
expect.objectContaining({ onSuccess: expect.any(Function) })
)
})
})
@@ -4,18 +4,21 @@ import { useState } from 'react'
import {
ButtonGroup,
ButtonGroupItem,
Chip,
ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
toast,
useCopyToClipboard,
} from '@sim/emcn'
import { Send } from '@sim/emcn/icons'
import { Check, Link, Send } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
import { GeneratedPasswordInput } from '@/components/ui'
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -30,22 +33,30 @@ interface ShareModalProps {
initialShare?: ShareRecord | null
}
type AccessMode = 'private' | ShareAuthType
const ACCESS_LABELS: Record<AccessMode, string> = {
private: 'Private',
const ACCESS_LABELS: Record<ShareAuthType, string> = {
public: 'Public',
password: 'Password',
email: 'Email',
sso: 'SSO',
}
const PRIMARY_ACTION_LABELS = {
share: { idle: 'Share', pending: 'Sharing...' },
update: { idle: 'Update', pending: 'Updating...' },
unshare: { idle: 'Unshare', pending: 'Unsharing...' },
} as const
const PRIMARY_ACTION_SUCCESS_MESSAGES = {
share: 'File shared',
update: 'Sharing updated',
unshare: 'File unshared',
} as const
/** Stable identity so the emails field's reconcile effect no-ops while unset. */
const EMPTY_EMAILS: string[] = []
function savedMode(share: ShareRecord | null): AccessMode {
if (!share?.isActive) return 'private'
return share.authType
function savedMode(share: ShareRecord | null): ShareAuthType {
return share?.authType ?? 'public'
}
export function ShareModal({
@@ -56,32 +67,26 @@ export function ShareModal({
fileName,
initialShare,
}: ShareModalProps) {
const { data: share, isFetched } = useFileShare(workspaceId, fileId, { enabled: open })
const {
data: share,
isError: isShareError,
isFetchedAfterMount,
} = useFileShare(workspaceId, fileId, { enabled: open })
const { config: permissionConfig } = usePermissionConfig()
const upsertShare = useUpsertFileShare()
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const saved = share ?? initialShare ?? null
const shareReadReady = isFetchedAfterMount && !isShareError
const saved = shareReadReady ? (share ?? null) : (share ?? initialShare ?? null)
const savedAccessMode = savedMode(saved)
// Reserve a token on open (one per mount — the modal remounts each open) so the
// link can be shown and copied before the first save; it's persisted on save.
// Only used once we've confirmed no share row exists yet, so a copied link
// always matches what gets stored.
const [pendingToken] = useState(() => generateShortId())
const noExistingShare = isFetched && !share && !initialShare
const shareUrl = saved?.url ?? (noExistingShare ? `${getBaseUrl()}/f/${pendingToken}` : null)
// `null` until the user changes the selector, so the control always reflects the
// authoritative saved state (which may resolve after mount via useFileShare).
const [draftMode, setDraftMode] = useState<AccessMode | null>(null)
const [draftMode, setDraftMode] = useState<ShareAuthType | null>(null)
const [draftPassword, setDraftPassword] = useState('')
const [draftEmails, setDraftEmails] = useState<string[] | null>(null)
const [unshareConfirmOpen, setUnshareConfirmOpen] = useState(false)
const effectiveMode = draftMode ?? savedAccessMode
const effectiveActive = effectiveMode !== 'private'
const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? EMPTY_EMAILS
// Org access-control may restrict which auth modes are allowed (`null` = all).
// The route is the source of truth; this just hides disallowed options.
const allowedAuthTypes = permissionConfig.allowedFileShareAuthTypes
const isAuthTypeAllowed = (mode: ShareAuthType) =>
allowedAuthTypes === null || allowedAuthTypes.includes(mode)
@@ -93,22 +98,16 @@ export function ShareModal({
'email',
...(ssoEnabled ? (['sso'] as const) : []),
]
// Keep the saved mode visible even if newly disallowed, so the current state shows.
const accessModes: AccessMode[] = [
'private',
...candidateAuthTypes.filter((mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode),
]
const accessModes = candidateAuthTypes.filter(
(mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode
)
// The selected mode is blocked when org policy disables public sharing entirely
// (enabling a new share) or when the chosen auth mode isn't allowed.
const modeDisallowed = effectiveMode !== 'private' && !isAuthTypeAllowed(effectiveMode)
const modeDisallowed = !isAuthTypeAllowed(effectiveMode)
const enableBlockedByPolicy =
(permissionConfig.disablePublicFileSharing && !saved?.isActive) || modeDisallowed
// A password share needs a secret: either one already stored or a freshly typed one.
const passwordMissing =
effectiveMode === 'password' && !saved?.hasPassword && draftPassword.trim().length === 0
// Email/SSO shares need at least one allowed email/domain.
const emailsMissing =
(effectiveMode === 'email' || effectiveMode === 'sso') && effectiveEmails.length === 0
@@ -119,6 +118,11 @@ export function ShareModal({
(draftMode !== null && draftMode !== savedAccessMode) ||
(effectiveMode === 'password' && draftPassword.length > 0) ||
((effectiveMode === 'email' || effectiveMode === 'sso') && emailsDirty)
const primaryAction = saved?.isActive ? (isDirty ? 'update' : 'unshare') : 'share'
const isUnshareAction = primaryAction === 'unshare'
const primaryActionPending = upsertShare.isPending || (isUnshareAction && unshareConfirmOpen)
const primaryLabel =
PRIMARY_ACTION_LABELS[primaryAction][primaryActionPending ? 'pending' : 'idle']
const resetDraft = () => {
setDraftMode(null)
@@ -127,122 +131,163 @@ export function ShareModal({
}
const handleClose = () => {
setUnshareConfirmOpen(false)
resetDraft()
onOpenChange(false)
}
const handleSave = () => {
// Persist the reserved token only when creating the row; existing shares keep
// their own token (the server ignores this on conflict).
const base = { workspaceId, fileId, token: saved ? undefined : pendingToken }
const vars =
effectiveMode === 'private'
? { ...base, isActive: false as const }
: effectiveMode === 'password'
const submitPrimaryAction = () => {
if (!shareReadReady || upsertShare.isPending) return
const base = { workspaceId, fileId, token: saved ? undefined : generateShortId() }
const vars = isUnshareAction
? { ...base, isActive: false as const }
: effectiveMode === 'password'
? {
...base,
isActive: true as const,
authType: 'password' as const,
password: draftPassword.trim() || undefined,
}
: effectiveMode === 'email' || effectiveMode === 'sso'
? {
...base,
isActive: true as const,
authType: 'password' as const,
password: draftPassword.trim() || undefined,
authType: effectiveMode,
allowedEmails: effectiveEmails,
}
: effectiveMode === 'email' || effectiveMode === 'sso'
? {
...base,
isActive: true as const,
authType: effectiveMode,
allowedEmails: effectiveEmails,
}
: { ...base, isActive: true as const, authType: 'public' as const }
: { ...base, isActive: true as const, authType: 'public' as const }
upsertShare.mutate(vars, {
onSuccess: () => {
toast.success(PRIMARY_ACTION_SUCCESS_MESSAGES[primaryAction])
setUnshareConfirmOpen(false)
resetDraft()
onOpenChange(false)
},
})
}
const handlePrimaryAction = () => {
if (isUnshareAction) {
setUnshareConfirmOpen(true)
return
}
submitPrimaryAction()
}
const accessHint = (() => {
if (isShareError) return 'Unable to load the current sharing settings. Close and try again.'
if (modeDisallowed) return 'This sharing method is disabled by an administrator.'
if (enableBlockedByPolicy)
return 'Public sharing is disabled for this workspace by an administrator.'
if (effectiveMode === 'private') return 'Only workspace members can access this file.'
if (effectiveMode === 'password')
return 'Anyone with the link and the password can view and download this file.'
if (effectiveMode === 'email')
return 'Only allowed emails can access this file after a one-time code.'
if (effectiveMode === 'sso')
return 'Only allowed emails signed in via SSO can access this file.'
return isDirty
? 'Save to make this file accessible to anyone with the link.'
: 'Anyone with the link can view and download this file.'
return saved?.isActive && !isDirty
? 'Anyone with the link can view and download this file.'
: `${saved?.isActive ? 'Update' : 'Share'} to make this file accessible to anyone with the link.`
})()
return (
<ChipModal open={open} onOpenChange={handleClose} size='sm' srTitle={`Share ${fileName}`}>
<ChipModalHeader icon={Send} onClose={handleClose}>
Share file
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='custom' title='Access' hint={accessHint}>
<ButtonGroup
value={effectiveMode}
onValueChange={(value) => setDraftMode(value as AccessMode)}
aria-label='File access'
>
{accessModes.map((mode) => (
<ButtonGroupItem key={mode} value={mode}>
{ACCESS_LABELS[mode]}
</ButtonGroupItem>
))}
</ButtonGroup>
</ChipModalField>
{effectiveMode === 'password' ? (
<ChipModalField
type='custom'
title='Password'
hint={
saved?.hasPassword
? 'Leave blank to keep the current password.'
: 'Anyone with the link must enter this password.'
}
>
<GeneratedPasswordInput
value={draftPassword}
onChange={setDraftPassword}
placeholder={saved?.hasPassword ? '••••••••' : 'Enter a password'}
/>
<>
<ChipModal
open={open}
onOpenChange={handleClose}
size='sm'
srTitle={`Share ${fileName}`}
dismissDisabled={upsertShare.isPending}
>
<ChipModalHeader icon={Send} onClose={handleClose}>
Share file
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='custom' title='Access' hint={accessHint}>
<ButtonGroup
value={effectiveMode}
onValueChange={(value) => setDraftMode(value as ShareAuthType)}
aria-label='File access'
disabled={upsertShare.isPending}
>
{accessModes.map((mode) => (
<ButtonGroupItem key={mode} value={mode}>
{ACCESS_LABELS[mode]}
</ButtonGroupItem>
))}
</ButtonGroup>
</ChipModalField>
) : null}
{effectiveMode === 'email' || effectiveMode === 'sso' ? (
<ChipModalField
type='emails'
title='Allowed emails'
value={effectiveEmails}
onChange={setDraftEmails}
validate={validateAllowlistEntry}
allowDomains
placeholder='Enter emails or domains'
placeholderWithTags='Add email or domain'
/>
) : null}
{effectiveMode !== 'private' && shareUrl ? (
<ChipModalField type='copy' title='Link' value={shareUrl} copyLabel='Copy link' />
) : null}
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
primaryAction={{
label: upsertShare.isPending ? 'Saving...' : 'Save',
onClick: handleSave,
disabled:
!isDirty ||
upsertShare.isPending ||
passwordMissing ||
emailsMissing ||
(effectiveActive && enableBlockedByPolicy),
{effectiveMode === 'password' ? (
<ChipModalField
type='custom'
title='Password'
hint={
saved?.hasPassword
? 'Leave blank to keep the current password.'
: 'Anyone with the link must enter this password.'
}
>
<GeneratedPasswordInput
value={draftPassword}
onChange={setDraftPassword}
placeholder={saved?.hasPassword ? '••••••••' : 'Enter a password'}
disabled={upsertShare.isPending}
/>
</ChipModalField>
) : null}
{effectiveMode === 'email' || effectiveMode === 'sso' ? (
<ChipModalField
type='emails'
title='Allowed emails'
value={effectiveEmails}
onChange={setDraftEmails}
validate={validateAllowlistEntry}
allowDomains
placeholder='Enter emails or domains'
placeholderWithTags='Add email or domain'
disabled={upsertShare.isPending}
/>
) : null}
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
secondaryActions={
saved?.isActive && saved.url
? [
{
custom: (
<Chip leftIcon={copied ? Check : Link} onClick={() => copy(saved.url)}>
{copied ? 'Copied!' : 'Copy link'}
</Chip>
),
},
]
: undefined
}
primaryAction={{
label: primaryLabel,
onClick: handlePrimaryAction,
variant: isUnshareAction ? 'destructive' : 'primary',
disabled:
upsertShare.isPending ||
!shareReadReady ||
(!isUnshareAction && (passwordMissing || emailsMissing || enableBlockedByPolicy)),
}}
/>
</ChipModal>
<ChipConfirmModal
open={open && unshareConfirmOpen}
onOpenChange={setUnshareConfirmOpen}
title='Unshare file?'
text='Are you sure you want to unshare this file? Anyone with the link will lose access.'
confirm={{
label: 'Unshare',
onClick: submitPrimaryAction,
pending: upsertShare.isPending,
pendingLabel: 'Unsharing...',
}}
/>
</ChipModal>
</>
)
}
@@ -16,8 +16,9 @@ import {
Trash,
toast,
Upload,
useCopyToClipboard,
} from '@sim/emcn'
import { Download, Send } from '@sim/emcn/icons'
import { Check, Download, Link, Send } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { useParams, useRouter } from 'next/navigation'
@@ -273,6 +274,7 @@ export function Files() {
const userPermissions = useUserPermissionsContext()
const canEdit = userPermissions.canEdit === true
const { config: permissionConfig } = usePermissionConfig()
const { copied: copiedFileLink, copy: copyFileLink } = useCopyToClipboard({ resetMs: 1500 })
// Joined for the live file tree: a `workspace-files-changed` broadcast invalidates the
// browser. "Who's in this file" comes from the file-doc room (see FileDocRoomProvider),
@@ -1397,6 +1399,19 @@ export function Files() {
closeContextMenu()
}, [selectedRowIds, handleBulkDownload, closeContextMenu, downloadArchive, handleDownload])
const handleContextMenuCopyLink = useCallback(() => {
const item = contextMenuItemRef.current
if (item?.kind === 'file') {
void copyFileLink(
`${window.location.origin}/workspace/${workspaceId}/files/${item.file.id}`
).then((copied) => {
if (copied) toast.success('Copied link to clipboard')
else toast.error('Failed to copy link')
})
}
closeContextMenu()
}, [closeContextMenu, copyFileLink, workspaceId])
const handleContextMenuRename = useCallback(() => {
const item = contextMenuItemRef.current
if (item?.kind === 'file') listRename.startRename(item.file.id, item.file.name)
@@ -1613,7 +1628,6 @@ export function Files() {
const isSimPage = selectedFile.type === SIM_PAGE_CONTENT_TYPE
const hasSplitView = canEditText && canPreview && !isInlineMarkdown && !isSimPage
const showPreviewToggle = canPreview && !isInlineMarkdown && !isSimPage
const nextModeLabel =
previewMode === 'editor' ? 'Split' : previewMode === 'split' ? 'Preview' : 'Edit'
const nextModeIcon =
@@ -1637,6 +1651,15 @@ export function Files() {
},
]
: []),
{
id: 'copy-link',
text: copiedFileLink ? 'Copied!' : 'Copy Link',
icon: copiedFileLink ? Check : Link,
onSelect: () =>
void copyFileLink(
`${window.location.origin}/workspace/${workspaceId}/files/${selectedFile.id}`
),
},
{
text: 'Download',
icon: Download,
@@ -1665,6 +1688,9 @@ export function Files() {
handleCyclePreviewMode,
handleTogglePreview,
handleDownloadSelected,
copiedFileLink,
copyFileLink,
workspaceId,
handleShareSelected,
handleDeleteSelected,
])
@@ -2244,6 +2270,7 @@ export function Files() {
position={contextMenuPosition}
onClose={closeContextMenu}
onOpen={handleContextMenuOpen}
onCopyLink={contextMenuItem?.kind === 'file' ? handleContextMenuCopyLink : undefined}
onDownload={handleContextMenuDownload}
onRename={handleContextMenuRename}
onDelete={handleContextMenuDelete}
+4 -1
View File
@@ -44,6 +44,7 @@ export function useFileShare(workspaceId: string, fileId: string, options?: { en
queryFn: ({ signal }) => fetchFileShare(workspaceId, fileId, signal),
enabled: Boolean(workspaceId) && Boolean(fileId) && (options?.enabled ?? true),
staleTime: FILE_SHARE_STALE_TIME,
refetchOnMount: 'always',
})
}
@@ -62,11 +63,13 @@ export function useUpsertFileShare() {
}),
onSuccess: (data, { workspaceId, fileId }) => {
queryClient.setQueryData(shareKeys.detail(workspaceId, fileId), data.share)
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
},
onError: (error) => {
toast.error(error.message)
},
onSettled: (_data, _error, { workspaceId }) => {
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
},
})
}
+1 -2
View File
@@ -48,8 +48,7 @@ export const upsertFileShareBodySchema = z.object({
.max(1024, 'Password is too long')
.optional(),
allowedEmails: z.array(allowedEmailSchema).max(200, 'Too many allowed emails').optional(),
// Client-reserved token shown as the link before saving; persisted on first
// enable so a copied link resolves. Ignored once the share row already exists.
/** Client-reserved token persisted on first share. Ignored once the share row exists. */
token: z
.string()
.regex(/^[A-Za-z0-9_-]+$/, 'Invalid token')