diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx index e3706877b81..fbc651748aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx @@ -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 )} + {!isMultiSelect && onCopyLink && ( + + + Copy Link + + )} {onDownload && ( @@ -109,7 +117,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ )} {!isMultiSelect && onShare && ( - + Share )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx new file mode 100644 index 00000000000..8eea65cc85f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx @@ -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: () => , + Link: () => , + Send: () => , +})) + +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 + }) => ( +
+ {Children.map(children, (child) => + isValidElement(child) + ? cloneElement(child as ReactElement, { + selectedValue: value, + onSelect: onValueChange, + disabled, + }) + : child + )} +
+ ), + ButtonGroupItem: ({ + value, + children, + selectedValue, + onSelect, + disabled, + }: MockButtonGroupItemProps) => ( + + ), + Chip: ({ + children, + leftIcon: LeftIcon, + onClick, + disabled, + }: { + children: ReactNode + leftIcon?: ComponentType<{ className?: string }> + onClick?: () => void + disabled?: boolean + }) => ( + + ), + ChipModal: ({ + open, + children, + dismissDisabled, + className, + }: { + open: boolean + children: ReactNode + dismissDisabled?: boolean + className?: string + }) => + open ? ( +
+ {children} +
+ ) : null, + ChipConfirmModal: ({ + open, + onOpenChange, + title, + text, + confirm, + }: { + open: boolean + onOpenChange: (open: boolean) => void + title: ReactNode + text?: ReactNode + confirm: MockFooterAction & { pending?: boolean; pendingLabel?: string } + }) => + open ? ( +
+

{title}

+ {text ?

{text}

: null} + + +
+ ) : null, + ChipModalHeader: ({ children, onClose }: { children: ReactNode; onClose: () => void }) => ( +
+ {children} + +
+ ), + ChipModalBody: ({ children, className }: { children: ReactNode; className?: string }) => ( +
+ {children} +
+ ), + ChipModalField: ({ + type, + title, + children, + value, + onChange, + hint, + disabled, + }: { + type: string + title: string + children?: ReactNode + value?: string[] + onChange?: (value: string[]) => void + hint?: ReactNode + disabled?: boolean + }) => ( +
+ {title} + {type === 'emails' ? ( + onChange?.(event.target.value.split(',').filter(Boolean))} + disabled={disabled} + /> + ) : ( + children + )} + {hint ?

{hint}

: null} +
+ ), + ChipModalFooter: ({ + onCancel, + primaryAction, + secondaryActions, + }: { + onCancel: () => void + primaryAction: MockFooterAction + secondaryActions?: MockFooterSlot[] + }) => ( +
+
+ {secondaryActions?.map((action, index) => + 'custom' in action ? ( + {action.custom} + ) : ( + + ) + )} +
+ + +
+ ), + useCopyToClipboard: () => ({ copied: false, copy: mockCopy }), +})) + +vi.mock('@/components/ui', () => ({ + GeneratedPasswordInput: ({ + value, + onChange, + placeholder, + disabled, + }: { + value: string + onChange: (value: string) => void + placeholder?: string + disabled?: boolean + }) => ( + 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 { + 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 void>> +let root: Root + +async function renderModal(initialShare: ShareRecord | null = null) { + await act(async () => { + root.render( + + ) + }) +} + +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('[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('[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('[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('[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( + 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) }) + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx index 073ce695588..26d9016c13d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx @@ -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 = { - private: 'Private', +const ACCESS_LABELS: Record = { 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(null) + const [draftMode, setDraftMode] = useState(null) const [draftPassword, setDraftPassword] = useState('') const [draftEmails, setDraftEmails] = useState(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 ( - - - Share file - - - - setDraftMode(value as AccessMode)} - aria-label='File access' - > - {accessModes.map((mode) => ( - - {ACCESS_LABELS[mode]} - - ))} - - - {effectiveMode === 'password' ? ( - - + <> + + + Share file + + + + setDraftMode(value as ShareAuthType)} + aria-label='File access' + disabled={upsertShare.isPending} + > + {accessModes.map((mode) => ( + + {ACCESS_LABELS[mode]} + + ))} + - ) : null} - {effectiveMode === 'email' || effectiveMode === 'sso' ? ( - - ) : null} - {effectiveMode !== 'private' && shareUrl ? ( - - ) : null} - - + + + ) : null} + {effectiveMode === 'email' || effectiveMode === 'sso' ? ( + + ) : null} + + copy(saved.url)}> + {copied ? 'Copied!' : 'Copy link'} + + ), + }, + ] + : undefined + } + primaryAction={{ + label: primaryLabel, + onClick: handlePrimaryAction, + variant: isUnshareAction ? 'destructive' : 'primary', + disabled: + upsertShare.isPending || + !shareReadReady || + (!isUnshareAction && (passwordMissing || emailsMissing || enableBlockedByPolicy)), + }} + /> + + - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index bd49e947710..c7f64d21a77 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -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} diff --git a/apps/sim/hooks/queries/public-shares.ts b/apps/sim/hooks/queries/public-shares.ts index 8793405055d..e2552b12531 100644 --- a/apps/sim/hooks/queries/public-shares.ts +++ b/apps/sim/hooks/queries/public-shares.ts @@ -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) }) + }, }) } diff --git a/apps/sim/lib/api/contracts/public-shares.ts b/apps/sim/lib/api/contracts/public-shares.ts index ef76f6d763f..41519234256 100644 --- a/apps/sim/lib/api/contracts/public-shares.ts +++ b/apps/sim/lib/api/contracts/public-shares.ts @@ -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')