diff --git a/package.json b/package.json index 2b05df3c07..bcbd898c33 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "@box/collaboration-popover": "^2.2.21", "@box/combobox-with-api": "2.2.25", "@box/content-field": "^3.1.17", - "@box/copy-input": "^1.42.16", + "@box/copy-input": "^2.3.21", "@box/frontend": "^11.0.1", "@box/item-icon": "^3.2.0", "@box/languages": "^1.0.0", @@ -148,9 +148,9 @@ "@box/readable-time": "^2.2.21", "@box/threaded-annotations": "^4.14.0", "@box/types": "^2.1.8", - "@box/unified-share-modal": "^3.5.25", + "@box/unified-share-modal": "^3.6.2", "@box/uploads-manager": "^2.4.0", - "@box/user-selector": "3.0.1", + "@box/user-selector": "^3.1.21", "@cfaester/enzyme-adapter-react-18": "^0.8.0", "@chromatic-com/storybook": "^5.2.1", "@commitlint/cli": "^19.8.0", @@ -313,7 +313,7 @@ "@box/collaboration-popover": "^2.2.21", "@box/combobox-with-api": "2.2.25", "@box/content-field": "^3.1.17", - "@box/copy-input": "^1.42.16", + "@box/copy-input": "^2.3.21", "@box/item-icon": "^3.2.0", "@box/metadata-editor": "^2.11.11", "@box/metadata-filter": "^1.80.23", @@ -325,9 +325,9 @@ "@box/readable-time": "^2.2.21", "@box/threaded-annotations": "^4.14.0", "@box/types": "^2.1.8", - "@box/unified-share-modal": "^3.5.25", + "@box/unified-share-modal": "^3.6.2", "@box/uploads-manager": "^2.4.0", - "@box/user-selector": "3.0.1", + "@box/user-selector": "^3.1.21", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/src/common/types/core.ts b/src/common/types/core.ts index 9cec33b24c..7e6863d9b4 100644 --- a/src/common/types/core.ts +++ b/src/common/types/core.ts @@ -109,7 +109,12 @@ type FolderMini = { type: typeof ITEM_TYPE_FOLDER; }; -type UserMini = { +type UserBase = { + id: string; + type: 'user'; +}; + +type UserMini = UserBase & { avatar_url?: string; email?: string; enterprise?: { @@ -118,11 +123,9 @@ type UserMini = { type: 'enterprise'; }; hostname?: string; - id: string; login?: string; name: string; status?: string; - type: 'user'; }; type ContactCollection = { @@ -290,6 +293,18 @@ type FileRepresentationResponse = { entries: Array; }; +type OtpAccessEntry = { + created_at: string; + created_by: UserBase; + type: 'email' | 'domain'; + value: string; +}; + +type OtpAccess = { + allowlist?: Array; + mode: 'none' | 'open' | 'allowlist'; +}; + type SharedLink = { access: Access; download_count?: number; @@ -297,6 +312,7 @@ type SharedLink = { effective_access?: Access; effective_permission?: typeof PERMISSION_CAN_DOWNLOAD | typeof PERMISSION_CAN_PREVIEW; is_password_enabled?: boolean; + one_time_passcode?: OtpAccess; password?: string | null; // the API requires a null value to remove a password, permissions?: BoxItemPermission; preview_count?: number; @@ -506,6 +522,7 @@ export type { Size, FileMini, FolderMini, + UserBase, UserMini, User, UserCollection, @@ -538,4 +555,5 @@ export type { Collaborations, NewCollaboration, DOMStringList, + OtpAccess, }; diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx index ed08a077cf..4d113da1dc 100644 --- a/src/elements/content-sharing/ContentSharingV2.tsx +++ b/src/elements/content-sharing/ContentSharingV2.tsx @@ -72,7 +72,6 @@ function ContentSharingV2({ const [collaborators, setCollaborators] = React.useState(null); const [collaboratorsData, setCollaboratorsData] = React.useState(null); const [owner, setOwner] = React.useState({ email: '', id: '', name: '' }); - const [isEnterpriseUser, setIsEnterpriseUser] = React.useState(null); const config = React.useMemo(() => { return { @@ -155,7 +154,6 @@ function ContentSharingV2({ setItem(null); setSharedLink(null); setCurrentUser(null); - setIsEnterpriseUser(null); setCollaborationRoles(null); setAvatarUrlMap(null); setCollaborators(null); @@ -186,15 +184,14 @@ function ContentSharingV2({ return; } - const { enterprise, hostname, id, login } = userData; + const { hostname, id, login } = userData; const emailDomain = login && /@/.test(login) ? login.split('@')[1] : undefined; + setCurrentUser({ id, email: login, emailDomain }); setSharingServiceProps(prevSharingServiceProps => ({ ...prevSharingServiceProps, serverUrl: hostname ? `${hostname}v/` : '', })); - - setIsEnterpriseUser(!!enterprise); }; (async () => { @@ -207,27 +204,6 @@ function ContentSharingV2({ })(); }, [api, currentUser, item, itemId, itemType, sharedLink, getError]); - // Set canChangeExpiration to false for non-enterprise (individual) users on every sharedLink update - React.useEffect(() => { - if (isEnterpriseUser !== false || !sharedLink?.settings?.canChangeExpiration) { - return; - } - - setSharedLink(prevSharedLink => { - if (!prevSharedLink) { - return prevSharedLink; - } - - return { - ...prevSharedLink, - settings: { - ...prevSharedLink.settings, - canChangeExpiration: false, - }, - }; - }); - }, [isEnterpriseUser, sharedLink]); - // Get collaborators React.useEffect(() => { if (!api || isEmpty(api) || !item || collaboratorsData) return; diff --git a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx index 79729c8f77..ac11b5d82d 100644 --- a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx +++ b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import { fireEvent, render, type RenderResult, screen, waitFor } from '@testing-library/react'; +import { render, type RenderResult, screen, waitFor } from '@testing-library/react'; import { Notification, TooltipProvider } from '@box/blueprint-web'; import { useSharingService } from '../hooks/useSharingService'; import { DEFAULT_ITEM_API_RESPONSE, DEFAULT_USER_API_RESPONSE, - FREE_USER_API_RESPONSE, MOCK_ITEM, MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK, MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION, @@ -118,9 +117,6 @@ describe('elements/content-sharing/ContentSharingV2', () => { expect(await screen.findByRole('button', { name: 'Can view and download' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Link Settings' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Copy' })).toBeVisible(); - // Should have expiration toggle enabled for Business+ users - fireEvent.click(screen.getByRole('button', { name: 'Link Settings' })); - expect(screen.getByRole('switch', { name: 'Link expiration' })).not.toBeDisabled(); }); test('should see the classification elements if classification is present', async () => { @@ -187,21 +183,6 @@ describe('elements/content-sharing/ContentSharingV2', () => { expect(await screen.findByRole('button', { name: 'Send Shared Link' })).toBeVisible(); }); - test('should have disabled expiration toggle for users without enterprise', async () => { - const freeUserApi = { - ...defaultApiMock, - getFileAPI: jest.fn().mockReturnValue({ getFile: getFileMockWithSharedLink }), - getUsersAPI: jest.fn().mockReturnValue({ - getUser: jest.fn().mockImplementation(createSuccessMock(FREE_USER_API_RESPONSE)), - getAvatarUrlWithAccessToken: getAvatarUrlMock, - }), - }; - - renderComponent({ api: freeUserApi }); - fireEvent.click(await screen.findByRole('button', { name: 'Link Settings' })); - expect(screen.getByRole('switch', { name: 'Link expiration' })).toBeDisabled(); - }); - describe('getError function', () => { const createErrorApi = error => ({ ...defaultApiMock, diff --git a/src/elements/content-sharing/__tests__/sharingService.test.ts b/src/elements/content-sharing/__tests__/sharingService.test.ts index 22705b7aab..4e9bd5efe4 100644 --- a/src/elements/content-sharing/__tests__/sharingService.test.ts +++ b/src/elements/content-sharing/__tests__/sharingService.test.ts @@ -1,8 +1,8 @@ -import { ACCESS_NONE, PERMISSION_CAN_DOWNLOAD, PERMISSION_CAN_PREVIEW } from '../../../constants'; -import { CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS } from '../constants'; import { createSharingService } from '../sharingService'; import { convertSharedLinkPermissions, convertSharedLinkSettings } from '../utils'; +import { CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS } from '../constants'; + jest.mock('../utils'); const mockItemApiInstance = { @@ -26,8 +26,8 @@ const createSharingServiceWrapper = (hasSharedLink = true) => { describe('elements/content-sharing/sharingService', () => { beforeEach(() => { (convertSharedLinkPermissions as jest.Mock).mockReturnValue({ - [PERMISSION_CAN_DOWNLOAD]: true, - [PERMISSION_CAN_PREVIEW]: false, + can_download: true, + can_preview: false, }); (convertSharedLinkSettings as jest.Mock).mockReturnValue({ unshared_at: null, @@ -48,10 +48,10 @@ describe('elements/content-sharing/sharingService', () => { test('should call updateSharedLink with correct parameters when changeSharedLinkPermission is called', async () => { const service = createSharingServiceWrapper(); - const permissionLevel = PERMISSION_CAN_DOWNLOAD; + const permissionLevel = 'can_download'; const expectedPermissions = { - [PERMISSION_CAN_DOWNLOAD]: true, - [PERMISSION_CAN_PREVIEW]: false, + can_download: true, + can_preview: false, }; await service.changeSharedLinkPermission(permissionLevel); @@ -68,7 +68,7 @@ describe('elements/content-sharing/sharingService', () => { test('should reject with 404 error when hasSharedLink is false', async () => { const service = createSharingServiceWrapper(false); - await expect(service.changeSharedLinkPermission(PERMISSION_CAN_DOWNLOAD)).rejects.toEqual( + await expect(service.changeSharedLinkPermission('can_download')).rejects.toEqual( expect.objectContaining({ message: 'Shared link not found', status: 404, @@ -149,7 +149,7 @@ describe('elements/content-sharing/sharingService', () => { expect(typeof service.deleteSharedLink).toBe('function'); }); - test('should call share with ACCESS_NONE and onRemoveSharedLink when deleteSharedLink is called', async () => { + test('should call share with correct parameters when deleteSharedLink is called', async () => { mockItemApiInstance.share.mockImplementation((_options, _access, successCallback) => { successCallback({ id: '123', shared_link: null }); }); @@ -159,7 +159,7 @@ describe('elements/content-sharing/sharingService', () => { expect(mockItemApiInstance.share).toHaveBeenCalledWith( options, - ACCESS_NONE, + 'none', expect.any(Function), expect.any(Function), CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS, @@ -188,7 +188,7 @@ describe('elements/content-sharing/sharingService', () => { }); test('should call updateSharedLink with basic shared link settings', async () => { - mockItemApiInstance.updateSharedLink.mockImplementation((_options, _access, successCallback) => { + mockItemApiInstance.updateSharedLink.mockImplementation((_options, _settings, successCallback) => { successCallback({ id: '123', shared_link: null }); }); @@ -198,7 +198,10 @@ describe('elements/content-sharing/sharingService', () => { expiration: null, isDownloadEnabled: true, isExpirationEnabled: false, + isOtpEnabled: false, isPasswordEnabled: false, + isVanityNameEnabled: true, + otpMode: 'none' as const, password: '', vanityName: 'vanity-name', }; @@ -212,10 +215,10 @@ describe('elements/content-sharing/sharingService', () => { expect(convertSharedLinkSettings).toHaveBeenCalledWith( sharedLinkSettings, + {}, // prevSettings undefined, // access - undefined, // isDownloadAvailable + undefined, // expiresAt undefined, // serverUrl - undefined, ); expect(mockItemApiInstance.updateSharedLink).toHaveBeenCalledWith( options, @@ -226,8 +229,8 @@ describe('elements/content-sharing/sharingService', () => { ); }); - test('should call updateSharedLink with options including access, isDownloadAvailable, and serverUrl', async () => { - mockItemApiInstance.updateSharedLink.mockImplementation((_options, _access, successCallback) => { + test('should call updateSharedLink with options', async () => { + mockItemApiInstance.updateSharedLink.mockImplementation((_options, _settings, successCallback) => { successCallback({ id: '123', shared_link: null }); }); @@ -248,9 +251,13 @@ describe('elements/content-sharing/sharingService', () => { options: { ...options, access: 'open', - currentExpiresAt: 1700000000000, - isDownloadAvailable: true, + expiresAt: 1735689600000, serverUrl: 'https://example.com/server-url', + settings: { + canChangeExpiration: true, + isDownloadAvailable: true, + otpMode: 'none', + }, }, }); @@ -258,7 +265,10 @@ describe('elements/content-sharing/sharingService', () => { expiration: null, isDownloadEnabled: false, isExpirationEnabled: true, + isOtpEnabled: false, isPasswordEnabled: true, + isVanityNameEnabled: true, + otpMode: 'none' as const, password: 'test-password', vanityName: 'vanity-name', }; @@ -267,10 +277,14 @@ describe('elements/content-sharing/sharingService', () => { expect(convertSharedLinkSettings).toHaveBeenCalledWith( sharedLinkSettings, + { + canChangeExpiration: true, + isDownloadAvailable: true, + otpMode: 'none', + }, 'open', - true, + 1735689600000, 'https://example.com/server-url', - 1700000000000, ); expect(mockItemApiInstance.updateSharedLink).toHaveBeenCalledWith( options, @@ -282,18 +296,20 @@ describe('elements/content-sharing/sharingService', () => { }); test('should handle shared link settings correctly', async () => { - mockItemApiInstance.updateSharedLink.mockImplementation((_options, _access, successCallback) => { + mockItemApiInstance.updateSharedLink.mockImplementation((_options, _settings, successCallback) => { successCallback({ id: '123', shared_link: null }); }); const service = createSharingServiceWrapper(); - const expirationDate = new Date('2024-12-31T23:59:59Z'); const sharedLinkSettings = { - expiration: expirationDate, + expiration: null, isDownloadEnabled: false, isExpirationEnabled: true, + isOtpEnabled: true, isPasswordEnabled: false, + isVanityNameEnabled: true, + otpMode: 'allowlist' as const, password: 'test-password', vanityName: 'vanity-name', }; @@ -302,7 +318,7 @@ describe('elements/content-sharing/sharingService', () => { expect(convertSharedLinkSettings).toHaveBeenCalledWith( sharedLinkSettings, - undefined, + {}, undefined, undefined, undefined, @@ -316,7 +332,10 @@ describe('elements/content-sharing/sharingService', () => { expiration: null, isDownloadEnabled: true, isExpirationEnabled: false, + isOtpEnabled: false, isPasswordEnabled: false, + isVanityNameEnabled: true, + otpMode: 'none' as const, password: '', vanityName: 'vanity-name', }; @@ -331,6 +350,63 @@ describe('elements/content-sharing/sharingService', () => { }); }); + describe('updateSharedLinkOtpAccess', () => { + test('should return an object with updateSharedLinkOtpAccess method', () => { + const service = createSharingServiceWrapper(); + + expect(service).toHaveProperty('updateSharedLinkOtpAccess'); + expect(typeof service.updateSharedLinkOtpAccess).toBe('function'); + }); + + test('should call updateSharedLink with the allowlist entries', async () => { + mockItemApiInstance.updateSharedLink.mockImplementation((_options, _settings, successCallback) => { + successCallback({ id: '123', shared_link: null }); + }); + + const service = createSharingServiceWrapper(); + + const otpAccessEntries = [ + { createdAt: 1769846400000, type: 'email' as const, value: 'dparrot@example.com' }, + { createdAt: 1769932800000, type: 'domain' as const, value: 'example.com' }, + ]; + + await service.updateSharedLinkOtpAccess(otpAccessEntries); + + expect(mockItemApiInstance.updateSharedLink).toHaveBeenCalledWith( + options, + { + one_time_passcode: { + allowlist: [ + { type: 'email', value: 'dparrot@example.com' }, + { type: 'domain', value: 'example.com' }, + ], + mode: 'allowlist', + }, + }, + expect.any(Function), + expect.any(Function), + CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS, + ); + }); + + test('should reject with 404 error when hasSharedLink is false', async () => { + const service = createSharingServiceWrapper(false); + + const otpAccessEntries = [ + { createdAt: 1769846400000, type: 'email' as const, value: 'dparrot@example.com' }, + { createdAt: 1769932800000, type: 'domain' as const, value: 'example.com' }, + ]; + + await expect(service.updateSharedLinkOtpAccess(otpAccessEntries)).rejects.toEqual( + expect.objectContaining({ + message: 'Shared link not found', + status: 404, + }), + ); + expect(mockItemApiInstance.updateSharedLink).not.toHaveBeenCalled(); + }); + }); + describe('error handling', () => { test('should reject when createSharedLink fails', async () => { const mockError = new Error('Failed to create shared link'); @@ -365,12 +441,31 @@ describe('elements/content-sharing/sharingService', () => { expiration: null, isDownloadEnabled: true, isExpirationEnabled: false, + isOtpEnabled: false, isPasswordEnabled: false, + isVanityNameEnabled: true, + otpMode: 'none' as const, password: '', vanityName: 'vanity-name', }; await expect(service.updateSharedLink(sharedLinkSettings)).rejects.toEqual(mockError); }); + + test('should reject when updateSharedLinkOtpAccess fails', async () => { + const mockError = new Error('Failed to update shared link OTP access'); + mockItemApiInstance.updateSharedLink.mockImplementation( + (_options, _settings, _successCallback, errorCallback) => { + errorCallback(mockError); + }, + ); + + const service = createSharingServiceWrapper(); + const otpAccessEntries = [ + { createdAt: 1769846400000, type: 'email' as const, value: 'dparrot@example.com' }, + ]; + + await expect(service.updateSharedLinkOtpAccess(otpAccessEntries)).rejects.toEqual(mockError); + }); }); }); diff --git a/src/elements/content-sharing/constants.js b/src/elements/content-sharing/constants.js index e73b2577e2..8dbe126bd0 100644 --- a/src/elements/content-sharing/constants.js +++ b/src/elements/content-sharing/constants.js @@ -80,6 +80,10 @@ export const API_TO_USM_CLASSIFICATION_COLORS_MAP = { [bdlGray20]: CLASSIFICATION_COLOR_ID_7, }; +export const OTP_MODE_ALLOWLIST = 'allowlist'; +export const OTP_MODE_NONE = 'none'; +export const OTP_MODE_OPEN = 'open'; + export const ANYONE_WITH_LINK = 'peopleWithTheLink'; export const ANYONE_IN_COMPANY = 'peopleInYourCompany'; export const PEOPLE_IN_ITEM = 'peopleInThisItem'; diff --git a/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts b/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts index 2c75dac640..f1cd3a0e14 100644 --- a/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts +++ b/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts @@ -125,7 +125,7 @@ describe('elements/content-sharing/hooks/useSharingService', () => { hasSharedLink: false, options: expect.objectContaining({ access: undefined, - isDownloadAvailable: false, + settings: undefined, }), }), ); @@ -178,11 +178,10 @@ describe('elements/content-sharing/hooks/useSharingService', () => { onRemoveSharedLink: expect.any(Function), options: { access: mockSharedLink.access, - currentExpiresAt: null, - isDownloadAvailable: mockSharedLink.settings.isDownloadAvailable, id: mockItemId, permissions: { can_set_share_access, can_share }, serverUrl, + settings: mockSharedLink.settings, }, }); }); @@ -237,11 +236,10 @@ describe('elements/content-sharing/hooks/useSharingService', () => { onRemoveSharedLink: expect.any(Function), options: { access: mockSharedLink.access, - currentExpiresAt: null, - isDownloadAvailable: mockSharedLink.settings.isDownloadAvailable, id: mockItemId, permissions: { can_set_share_access, can_share }, serverUrl, + settings: mockSharedLink.settings, }, }); }); diff --git a/src/elements/content-sharing/hooks/useSharingService.ts b/src/elements/content-sharing/hooks/useSharingService.ts index 909de1a7c5..7f43817713 100644 --- a/src/elements/content-sharing/hooks/useSharingService.ts +++ b/src/elements/content-sharing/hooks/useSharingService.ts @@ -50,16 +50,19 @@ export const useSharingService = ({ return null; } + const { access, expiresAt, settings } = sharedLink ?? {}; + const { can_set_share_access, can_share, serverUrl } = sharingServiceProps ?? {}; + const options = { + access, + expiresAt, id: itemId, - access: sharedLink?.access, + settings, permissions: { - can_set_share_access: sharingServiceProps?.can_set_share_access, - can_share: sharingServiceProps?.can_share, + can_set_share_access, + can_share, }, - serverUrl: sharingServiceProps?.serverUrl, - currentExpiresAt: sharedLink?.expiresAt ?? null, - isDownloadAvailable: sharedLink?.settings?.isDownloadAvailable ?? false, + serverUrl, }; const handleUpdateSharedLink = updatedItemData => { diff --git a/src/elements/content-sharing/sharingService.ts b/src/elements/content-sharing/sharingService.ts index 0a82dfcc65..78f63a19f8 100644 --- a/src/elements/content-sharing/sharingService.ts +++ b/src/elements/content-sharing/sharingService.ts @@ -1,9 +1,10 @@ +import type { LinkSettingsFormData, OtpAccessEntry, SharedLinkSettings } from '@box/unified-share-modal'; + import type { API } from '../../api'; -import { ACCESS_NONE } from '../../constants'; -import { CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS } from './constants'; import { convertSharedLinkPermissions, convertSharedLinkSettings } from './utils'; -import type { SharedLinkSettings } from './types'; +import { ACCESS_NONE } from '../../constants'; +import { CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS, OTP_MODE_ALLOWLIST } from './constants'; export interface ItemData { id: string; @@ -15,9 +16,9 @@ export interface ItemData { export interface Options extends ItemData { access?: string; - currentExpiresAt?: number | null; - isDownloadAvailable?: boolean; + expiresAt?: number; serverUrl?: string; + settings?: SharedLinkSettings; } export interface CreateSharingServiceArgs { @@ -65,17 +66,43 @@ export const createSharingService = ({ ); }; - const updateSharedLink = async (sharedLinkSettings: SharedLinkSettings) => { + const updateSharedLink = async (nextSettings: LinkSettingsFormData) => { if (!hasSharedLink) { return Promise.reject(Object.assign(new Error('Shared link not found'), { status: 404 })); } - const { access, currentExpiresAt, isDownloadAvailable, serverUrl } = options; + const { access, expiresAt, serverUrl, settings: prevSettings = {} } = options; return new Promise((resolve, reject) => { itemApiInstance.updateSharedLink( { id, permissions }, - convertSharedLinkSettings(sharedLinkSettings, access, isDownloadAvailable, serverUrl, currentExpiresAt), + convertSharedLinkSettings(nextSettings, prevSettings, access, expiresAt, serverUrl), + data => { + onUpdateSharedLink(data); + resolve(data); + }, + error => { + reject(error); + }, + CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS, + ); + }); + }; + + const updateSharedLinkOtpAccess = async (entries: OtpAccessEntry[]) => { + if (!hasSharedLink) { + return Promise.reject(Object.assign(new Error('Shared link not found'), { status: 404 })); + } + + return new Promise((resolve, reject) => { + itemApiInstance.updateSharedLink( + { id, permissions }, + { + one_time_passcode: { + allowlist: entries.map(({ type, value }) => ({ type, value })), + mode: OTP_MODE_ALLOWLIST, + }, + }, data => { onUpdateSharedLink(data); resolve(data); @@ -132,5 +159,6 @@ export const createSharingService = ({ changeSharedLinkPermission, deleteSharedLink, updateSharedLink, + updateSharedLinkOtpAccess, }; }; diff --git a/src/elements/content-sharing/stories/ContentSharingV2.stories.tsx b/src/elements/content-sharing/stories/ContentSharingV2.stories.tsx index d2f86b14c3..0ef498cd02 100644 --- a/src/elements/content-sharing/stories/ContentSharingV2.stories.tsx +++ b/src/elements/content-sharing/stories/ContentSharingV2.stories.tsx @@ -1,5 +1,9 @@ import { TYPE_FILE, TYPE_FOLDER } from '../../../constants'; -import { ContentSharingV2Template, mockApiWithSharedLink } from '../utils/__mocks__/ContentSharingV2Mocks'; +import { + ContentSharingV2Template, + mockApiWithOneTimePasscode, + mockApiWithSharedLink, +} from '../utils/__mocks__/ContentSharingV2Mocks'; export const basic = {}; @@ -9,6 +13,12 @@ export const withSharedLink = { }, }; +export const withOneTimePasscode = { + args: { + api: mockApiWithOneTimePasscode, + }, +}; + export const desktopVariant = { args: { api: mockApiWithSharedLink, diff --git a/src/elements/content-sharing/types.js b/src/elements/content-sharing/types.js index b9ac34b50e..df7fc905dc 100644 --- a/src/elements/content-sharing/types.js +++ b/src/elements/content-sharing/types.js @@ -1,5 +1,5 @@ // @flow -import type { CollaborationRole, Collaborator, DateValue, Item, SharedLink, User } from '@box/unified-share-modal'; +import type { CollaborationRole, Collaborator, Item, SharedLink, User } from '@box/unified-share-modal'; import API from '../../api'; @@ -183,15 +183,6 @@ export interface FetchCollaboratorsProps extends BaseFetchProps { collaborators: Collaboration[]; } -export interface SharedLinkSettings { - expiration: ?DateValue; - isDownloadEnabled: boolean; - isExpirationEnabled: boolean; - isPasswordEnabled: boolean; - password: string; - vanityName: string; -} - export interface ContentSharingUser extends User { email?: string; emailDomain?: string; diff --git a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js index d39432a037..a1590e821f 100644 --- a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js +++ b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js @@ -35,6 +35,24 @@ export const MOCK_SHARED_LINK = { vanity_url: 'https://example.com/vanity-url', }; +export const MOCK_ONE_TIME_PASSCODE = { + allowlist: [ + { + created_at: '2026-01-31T08:00:00Z', + created_by: { id: '789', type: 'user' }, + type: 'email', + value: 'dparrot@example.com', + }, + { + created_at: '2026-01-25T08:00:00Z', + created_by: { id: '789', type: 'user' }, + type: 'domain', + value: 'example.com', + }, + ], + mode: 'allowlist', +}; + export const mockAvatarUrlMap = { 456: 'https://example.com/avatar.jpg', }; @@ -94,15 +112,9 @@ export const EMPTY_COLLABORATIONS_RESPONSE = { export const DEFAULT_USER_API_RESPONSE = { id: '789', - enterprise: { id: '12345', name: 'Test Enterprise' }, login: mockOwner.login, }; -export const FREE_USER_API_RESPONSE = { - id: '123', - enterprise: null, -}; - export const DEFAULT_ITEM_API_RESPONSE = { allowed_invitee_roles: ['editor', 'viewer'], allowed_shared_link_access_levels: ['open', 'company', 'collaborators'], @@ -135,6 +147,15 @@ export const MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION = { classification: MOCK_CLASSIFICATION, }; +export const MOCK_ITEM_API_RESPONSE_WITH_ONE_TIME_PASSCODE = { + ...DEFAULT_ITEM_API_RESPONSE, + shared_link: { + ...MOCK_SHARED_LINK, + is_password_enabled: false, + one_time_passcode: MOCK_ONE_TIME_PASSCODE, + }, +}; + // Mock API class for ContentSharingV2 storybook export const createMockApi = ( itemResponse = DEFAULT_ITEM_API_RESPONSE, @@ -207,6 +228,11 @@ export const mockApiWithCollaborators = createMockApi( DEFAULT_USER_API_RESPONSE, MOCK_COLLABORATIONS_RESPONSE, ); +export const mockApiWithOneTimePasscode = createMockApi( + MOCK_ITEM_API_RESPONSE_WITH_ONE_TIME_PASSCODE, + DEFAULT_USER_API_RESPONSE, + EMPTY_COLLABORATIONS_RESPONSE, +); export const ContentSharingV2Template = (props = {}) => { return ( diff --git a/src/elements/content-sharing/utils/__tests__/convertItemResponse.test.ts b/src/elements/content-sharing/utils/__tests__/convertItemResponse.test.ts index ca72286e8d..d9508d8dfc 100644 --- a/src/elements/content-sharing/utils/__tests__/convertItemResponse.test.ts +++ b/src/elements/content-sharing/utils/__tests__/convertItemResponse.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_ITEM_API_RESPONSE, MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION, + MOCK_ITEM_API_RESPONSE_WITH_ONE_TIME_PASSCODE, MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK, mockOwnerEmail, mockOwnerId, @@ -86,14 +87,18 @@ describe('convertItemResponse', () => { settings: { canChangeDownload: true, canChangeExpiration: true, + canChangeOtp: false, canChangePassword: true, canChangeVanityName: false, isDirectLinkAvailable: true, isDownloadAvailable: true, isDownloadEnabled: true, + isOtpAvailable: false, isPasswordAvailable: true, isPasswordEnabled: true, isVanityNameAvailable: true, + otpAccessEntries: undefined, + otpMode: undefined, }, url: 'https://example.com/shared-link', vanityDomain: 'https://example.com/vanity-url', @@ -151,18 +156,33 @@ describe('convertItemResponse', () => { expect(result.sharedLink.settings.isVanityNameAvailable).toEqual(false); }); - test.each([ - ['expiration false leaves the switch not editable', false, false], - ['expiration true leaves the switch editable', true, true], - ])('%s', (_name, expiration, expected) => { + test('should convert OTP settings correctly if user cannot change access level', () => { const result = convertItemResponse({ - ...MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK, - shared_link_features: { - ...MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK.shared_link_features, - expiration, + ...MOCK_ITEM_API_RESPONSE_WITH_ONE_TIME_PASSCODE, + permissions: { + ...MOCK_ITEM_API_RESPONSE_WITH_ONE_TIME_PASSCODE.permissions, + can_set_share_access: false, }, }); - expect(result.sharedLink.settings.canChangeExpiration).toEqual(expected); + + expect(result.sharedLink.settings.canChangeOtp).toBe(false); + expect(result.sharedLink.settings.isOtpAvailable).toBe(true); + }); + + test('should convert OTP settings correctly if there are allowlist entries', () => { + const result = convertItemResponse(MOCK_ITEM_API_RESPONSE_WITH_ONE_TIME_PASSCODE); + + expect(result.sharedLink.settings).toEqual( + expect.objectContaining({ + canChangeOtp: true, + isOtpAvailable: true, + otpAccessEntries: [ + { createdAt: Date.parse('2026-01-31T08:00:00Z'), type: 'email', value: 'dparrot@example.com' }, + { createdAt: Date.parse('2026-01-25T08:00:00Z'), type: 'domain', value: 'example.com' }, + ], + otpMode: 'allowlist', + }), + ); }); }); diff --git a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts index 6a23cd19a6..626fb52474 100644 --- a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts +++ b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts @@ -1,3 +1,7 @@ +import { parseDate } from '@box/blueprint-web'; + +import { convertSharedLinkPermissions, convertSharedLinkSettings } from '../convertSharingServiceData'; + import { ACCESS_COLLAB, ACCESS_COMPANY, @@ -6,24 +10,8 @@ import { PERMISSION_CAN_EDIT, PERMISSION_CAN_PREVIEW, } from '../../../../constants'; -import { convertISOStringToUTCDate } from '../../../../utils/datetime'; -import { convertSharedLinkPermissions, convertSharedLinkSettings } from '../convertSharingServiceData'; - -jest.mock('../../../../utils/datetime', () => ({ - ...jest.requireActual('../../../../utils/datetime'), - convertISOStringToUTCDate: jest.fn(), -})); describe('elements/content-sharing/utils/convertSharingServiceData', () => { - beforeEach(() => { - // Mock convertISOStringToUTCDate to return a the same date as the expiration date to simplify the test logic - (convertISOStringToUTCDate as jest.Mock).mockReturnValue(new Date('2024-12-31T23:59:59Z')); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - describe('convertSharedLinkPermissions', () => { test.each([ [ @@ -52,17 +40,26 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { describe('convertSharedLinkSettings', () => { const mockServerUrl = 'https://example.com/server-url/'; const mockSettings = { - expiration: new Date('2024-12-31T23:59:59Z'), + expiration: parseDate('2024-12-31'), isDownloadEnabled: true, isExpirationEnabled: true, + isOtpEnabled: false, isPasswordEnabled: true, + isVanityNameEnabled: true, + otpMode: 'none' as const, password: 'test-password', vanityName: 'vanity-name', }; + const mockPrevSettings = { + canChangeExpiration: true, + isDownloadAvailable: true, + isOtpAvailable: false, + otpMode: 'none' as const, + }; describe('basic conversion', () => { test('should return settings with correct values', () => { - const result = convertSharedLinkSettings(mockSettings, ACCESS_OPEN, true, mockServerUrl); + const result = convertSharedLinkSettings(mockSettings, mockPrevSettings, ACCESS_OPEN, 0, mockServerUrl); expect(result).toEqual({ password: 'test-password', @@ -70,29 +67,36 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { can_preview: false, can_download: true, }, - unshared_at: '2024-12-31T23:59:59.000Z', + unshared_at: '2024-12-31T08:00:00.000Z', vanity_url: 'https://example.com/server-url/vanity-name', }); }); test('should handle minimal settings', () => { - const minimalSettings = { + const nextSettings = { expiration: null, isDownloadEnabled: false, isExpirationEnabled: false, + isOtpEnabled: false, isPasswordEnabled: false, + isVanityNameEnabled: false, + otpMode: 'none' as const, password: '', vanityName: '', }; + const prevSettings = { + ...mockPrevSettings, + canChangeExpiration: false, + isDownloadAvailable: false, + }; - const result = convertSharedLinkSettings(minimalSettings, ACCESS_OPEN, false, ''); + const result = convertSharedLinkSettings(nextSettings, prevSettings, ACCESS_OPEN, 0, ''); expect(result).toEqual({ password: null, permissions: { can_preview: true, }, - unshared_at: null, vanity_url: '', }); }); @@ -100,94 +104,59 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { describe('expiration handling', () => { test('should set unshared_at to null when expiration is disabled', () => { - const settingsWithoutExpiration = { + const nextSettings = { ...mockSettings, isExpirationEnabled: false, - expiration: new Date('2024-12-31T23:59:59Z'), + expiration: parseDate('2024-12-31'), }; - const result = convertSharedLinkSettings(settingsWithoutExpiration, ACCESS_OPEN, true, mockServerUrl); + const result = convertSharedLinkSettings(nextSettings, mockPrevSettings, ACCESS_OPEN, 0, mockServerUrl); expect(result.unshared_at).toBeNull(); }); test.each([null, undefined])('should set unshared_at to null when expiration is %s', expiration => { - const settingsWithNullExpiration = { + const nextSettings = { ...mockSettings, isExpirationEnabled: true, expiration, }; - const result = convertSharedLinkSettings(settingsWithNullExpiration, ACCESS_OPEN, true, mockServerUrl); + const result = convertSharedLinkSettings(nextSettings, mockPrevSettings, ACCESS_OPEN, 0, mockServerUrl); expect(result.unshared_at).toBeNull(); }); - test('should keep the other shared link fields when the calendar date is omitted', () => { - const result = convertSharedLinkSettings( - { - ...mockSettings, - expiration: { year: 2026, month: 10, day: 16 }, - isDownloadEnabled: false, - }, - ACCESS_OPEN, - true, - mockServerUrl, - new Date(2026, 9, 16, 2, 59, 0).getTime(), - ); - - expect(result.unshared_at).toBeUndefined(); - expect(result.permissions).toEqual({ can_preview: true, can_download: false }); - }); - - test('should include unshared_at when the submitted date is a different local calendar day', () => { - const settings = { - ...mockSettings, - expiration: { year: 2024, month: 12, day: 31, toString: () => '2024-12-31' }, - }; - - const result = convertSharedLinkSettings( - settings, - ACCESS_OPEN, - true, - mockServerUrl, - new Date(2024, 11, 30, 23, 0, 0).getTime(), - ); - - expect(result.unshared_at).toBe('2024-12-31T23:59:59.000Z'); - }); + test('should not set unshared_at when expiration is unchanged', () => { + const expiresAt = 1735707600000; // 2024-12-31T21:00:00-08:00 - test('should include unshared_at when expiration is toggled on and there was no current expiration', () => { const result = convertSharedLinkSettings( - { ...mockSettings, expiration: { year: 2024, month: 12, day: 31, toString: () => '2024-12-31' } }, + mockSettings, + mockPrevSettings, ACCESS_OPEN, - true, + expiresAt, mockServerUrl, - null, ); - expect(result.unshared_at).toBe('2024-12-31T23:59:59.000Z'); + expect(result).not.toHaveProperty('unshared_at'); }); - test('should set unshared_at to null when expiration is toggled off and there was a current expiration', () => { - const settingsWithoutExpiration = { - ...mockSettings, - isExpirationEnabled: false, - }; + test('should set unshared_at when expiration is changed', () => { + const expiresAt = 1735621200000; // 2024-12-30T21:00:00-08:00 const result = convertSharedLinkSettings( - settingsWithoutExpiration, + mockSettings, + mockPrevSettings, ACCESS_OPEN, - true, + expiresAt, mockServerUrl, - new Date(2024, 11, 31, 18, 30, 0).getTime(), ); - expect(result.unshared_at).toBeNull(); + expect(result.unshared_at).toBe('2024-12-31T08:00:00.000Z'); }); }); - describe('vanity URL', () => { + describe('vanity URL handling', () => { test.each([ ['', ''], ['', 'vanity-name'], @@ -195,12 +164,12 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { ])( 'should return empty string when at least one of serverURL or vanityName is empty', (serverUrl, vanityName) => { - const result = convertSharedLinkSettings( - { ...mockSettings, vanityName }, - ACCESS_OPEN, - true, - serverUrl, - ); + const nextSettings = { + ...mockSettings, + vanityName, + }; + + const result = convertSharedLinkSettings(nextSettings, mockPrevSettings, ACCESS_OPEN, 0, serverUrl); expect(result.vanity_url).toBe(''); }, @@ -209,23 +178,24 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { describe('permissions handling', () => { test('should not set permissions for ACCESS_COLLAB access level', () => { - const result = convertSharedLinkSettings(mockSettings, ACCESS_COLLAB, true, mockServerUrl); + const result = convertSharedLinkSettings( + mockSettings, + mockPrevSettings, + ACCESS_COLLAB, + 0, + mockServerUrl, + ); expect(result.permissions).toBeUndefined(); }); test('should set permissions with download disabled when isDownloadEnabled is false', () => { - const settingsWithDownloadDisabled = { + const nextSettings = { ...mockSettings, isDownloadEnabled: false, }; - const result = convertSharedLinkSettings( - settingsWithDownloadDisabled, - ACCESS_OPEN, - true, - mockServerUrl, - ); + const result = convertSharedLinkSettings(nextSettings, mockPrevSettings, ACCESS_OPEN, 0, mockServerUrl); expect(result.permissions).toEqual({ can_preview: true, @@ -234,7 +204,12 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { }); test('should not set can_download when isDownloadAvailable is false', () => { - const result = convertSharedLinkSettings(mockSettings, ACCESS_OPEN, false, mockServerUrl); + const prevSettings = { + ...mockPrevSettings, + isDownloadAvailable: false, + }; + + const result = convertSharedLinkSettings(mockSettings, prevSettings, ACCESS_OPEN, 0, mockServerUrl); expect(result.permissions).toEqual({ can_preview: false, @@ -245,39 +220,88 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { describe('password handling', () => { test('should set password to null when isPasswordEnabled is false', () => { - const settingsWithPasswordDisabled = { + const nextSettings = { ...mockSettings, isPasswordEnabled: false, password: 'existingpassword', }; - const result = convertSharedLinkSettings( - settingsWithPasswordDisabled, - ACCESS_OPEN, - true, - mockServerUrl, - ); + const result = convertSharedLinkSettings(nextSettings, mockPrevSettings, ACCESS_OPEN, 0, mockServerUrl); expect(result.password).toBeNull(); }); test('should not set password when isPasswordEnabled is true but password is empty', () => { - const settingsWithEmptyPassword = { + const nextSettings = { ...mockSettings, isPasswordEnabled: true, password: '', }; - const result = convertSharedLinkSettings(settingsWithEmptyPassword, ACCESS_OPEN, true, mockServerUrl); + const result = convertSharedLinkSettings(nextSettings, mockPrevSettings, ACCESS_OPEN, 0, mockServerUrl); expect(result.password).toBeUndefined(); }); test.each([ACCESS_COLLAB, ACCESS_COMPANY])('should not set password for non open access level', access => { - const result = convertSharedLinkSettings(mockSettings, access, true, mockServerUrl); + const result = convertSharedLinkSettings(mockSettings, mockPrevSettings, access, 0, mockServerUrl); expect(result.password).toBeUndefined(); }); }); + + describe('one time password handling', () => { + test('should not set one_time_passcode when otp is not available', () => { + const nextSettings = { + ...mockSettings, + isPasswordEnabled: false, + }; + const prevSettings = { + isOtpAvailable: false, + }; + + const result = convertSharedLinkSettings(nextSettings, prevSettings, ACCESS_OPEN, 0, mockServerUrl); + + expect(result).not.toHaveProperty('one_time_passcode'); + }); + + test('should not set one_time_passcode when otp mode is unchanged', () => { + const nextSettings = { + ...mockSettings, + isPasswordEnabled: false, + }; + const prevSettings = { + isOtpAvailable: true, + otpMode: 'none' as const, + }; + + const result = convertSharedLinkSettings(nextSettings, prevSettings, ACCESS_OPEN, 0, mockServerUrl); + + expect(result).not.toHaveProperty('one_time_passcode'); + }); + + test.each([ + ['none', 'open', { mode: 'open' }], + ['none', 'allowlist', { allowlist: [], mode: 'allowlist' }], + ['open', 'none', { mode: 'none' }], + ] as const)( + 'should set one_time_passcode when otp mode changes from %s to %s', + (prevMode, nextMode, expected) => { + const nextSettings = { + ...mockSettings, + isPasswordEnabled: false, + otpMode: nextMode, + }; + const prevSettings = { + isOtpAvailable: true, + otpMode: prevMode, + }; + + const result = convertSharedLinkSettings(nextSettings, prevSettings, ACCESS_OPEN, 0, mockServerUrl); + + expect(result.one_time_passcode).toEqual(expected); + }, + ); + }); }); }); diff --git a/src/elements/content-sharing/utils/convertItemResponse.ts b/src/elements/content-sharing/utils/convertItemResponse.ts index 50cdf14504..7c6710210e 100644 --- a/src/elements/content-sharing/utils/convertItemResponse.ts +++ b/src/elements/content-sharing/utils/convertItemResponse.ts @@ -31,9 +31,9 @@ export const convertItemResponse = (itemApiData: ContentSharingItemAPIResponse): } = shared_link_features; const { - can_download: isDownloadSettingAvailable, - can_invite_collaborator: canInvite, - can_set_share_access: canChangeAccessLevel, + can_download: isDownloadAvailable, + can_invite_collaborator: canInviteCollaborator, + can_set_share_access: canSetShareAccess, can_share: canShare, } = permissions; @@ -41,6 +41,7 @@ export const convertItemResponse = (itemApiData: ContentSharingItemAPIResponse): let classificationData; if (classification) { const { color, definition, name: classificationName } = classification; + classificationData = { colorId: API_TO_USM_CLASSIFICATION_COLORS_MAP[color], definition, @@ -55,15 +56,32 @@ export const convertItemResponse = (itemApiData: ContentSharingItemAPIResponse): effective_access: access, effective_permission: permission, is_password_enabled: isPasswordEnabled, + one_time_passcode: oneTimePassword, unshared_at: expirationTimestamp, url, vanity_name: vanityName, vanity_url: vanityUrl, } = shared_link; - const isDownloadAllowed = permission === PERMISSION_CAN_DOWNLOAD || permission === PERMISSION_CAN_EDIT; - const canChangeDownload = canChangeAccessLevel && isDownloadSettingAvailable && access !== ACCESS_COLLAB; // access must be "company" or "open" - const canChangePassword = canChangeAccessLevel && isPasswordAvailable; + const isOtpAvailable = !!oneTimePassword; + const isDownloadEnabled = permission === PERMISSION_CAN_DOWNLOAD || permission === PERMISSION_CAN_EDIT; + + const canChangeDownload = canSetShareAccess && isDownloadAvailable && access !== ACCESS_COLLAB; // access must be "company" or "open" + const canChangePassword = canSetShareAccess && isPasswordAvailable; + const canChangeOtp = canSetShareAccess && isOtpAvailable; + + let otpAccessEntries; + let otpMode; + if (isOtpAvailable) { + const { allowlist = [], mode } = oneTimePassword; + + otpAccessEntries = allowlist.map(entry => ({ + createdAt: new Date(entry.created_at).getTime(), + type: entry.type, + value: entry.value, + })); + otpMode = mode; + } sharedLink = { access, @@ -76,18 +94,22 @@ export const convertItemResponse = (itemApiData: ContentSharingItemAPIResponse): permission, permissionLevels: shared_link_permission_options ?? - getAllowedPermissionLevels(canChangeAccessLevel, isDownloadSettingAvailable, permission), + getAllowedPermissionLevels(canSetShareAccess, isDownloadAvailable, permission), settings: { canChangeDownload, canChangeExpiration, + canChangeOtp, canChangePassword, canChangeVanityName: false, isDirectLinkAvailable, - isDownloadAvailable: isDownloadSettingAvailable, - isDownloadEnabled: isDownloadAllowed, + isDownloadAvailable, + isDownloadEnabled, + isOtpAvailable, isPasswordAvailable, isPasswordEnabled, isVanityNameAvailable, + otpAccessEntries, + otpMode, }, url, vanityDomain: vanityUrl, @@ -109,16 +131,16 @@ export const convertItemResponse = (itemApiData: ContentSharingItemAPIResponse): id, name, permissions: { - canInviteCollaborator: !!canInvite, - canSetShareAccess: canChangeAccessLevel, - canShare: !!canShare, + canInviteCollaborator, + canSetShareAccess, + canShare, }, type, }, ownedBy, sharedLink, sharingService: { - can_set_share_access: canChangeAccessLevel, + can_set_share_access: canSetShareAccess, can_share: canShare, ownerEmail: ownedBy.login, ownerId: ownedBy.id, diff --git a/src/elements/content-sharing/utils/convertSharingServiceData.ts b/src/elements/content-sharing/utils/convertSharingServiceData.ts index 67c53cf6cb..ec9e115f9a 100644 --- a/src/elements/content-sharing/utils/convertSharingServiceData.ts +++ b/src/elements/content-sharing/utils/convertSharingServiceData.ts @@ -1,3 +1,6 @@ +import { fromAbsolute, getLocalTimeZone, toCalendarDate } from '@box/blueprint-web'; +import { type LinkSettingsFormData, type SharedLinkSettings } from '@box/unified-share-modal'; + import { ACCESS_COLLAB, ACCESS_OPEN, @@ -5,11 +8,12 @@ import { PERMISSION_CAN_EDIT, PERMISSION_CAN_PREVIEW, } from '../../../constants'; -import { convertISOStringToUTCDate } from '../../../utils/datetime'; +import { OTP_MODE_ALLOWLIST } from '../constants'; -import type { SharedLinkSettings } from '../types'; +import type { OtpAccess } from '../../../common/types/core'; export interface ConvertSharedLinkSettingsReturnType { + one_time_passcode?: OtpAccess; password?: string | null; permissions?: { can_download?: boolean; @@ -34,94 +38,64 @@ export const convertSharedLinkPermissions = (permissionLevel: string) => { }; }; -/** - * Convert a shared link settings object from the USM into the format that the API expects. - * This function compares the provided access level to both API and internal USM access level constants, to accommodate two potential flows: - * - Changing the settings for a shared link right after the shared link has been created. The access level is saved directly from the data - * returned by the API, so it is in API format. - * - Changing the settings for a shared link in any other scenario. The access level is saved from the initial calls to the Item API and - * convertItemResponse, so it is in internal USM format. - */ -const pad = (value: number) => String(value).padStart(2, '0'); - -/** - * Normalize dates from the API (number) and content-sharing (object) to a string in the format YYYY-MM-DD. - */ -const normalizeDateToYearMonthDay = (value: number | { day: number; month: number; year: number }) => { - // currentExpiresAt comes in as a number - if (typeof value === 'number') { - const date = new Date(value); - - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - } - - // User-set expiration comes in as an object - return `${value.year}-${pad(value.month)}-${pad(value.day)}`; -}; - export const convertSharedLinkSettings = ( - newSettings: SharedLinkSettings, + nextSettings: LinkSettingsFormData, + prevSettings: SharedLinkSettings, accessLevel: string, - isDownloadAvailable: boolean, + expiresAt: number, serverUrl: string, - currentExpiresAt?: number | null, ): ConvertSharedLinkSettingsReturnType => { - const { - expiration: newExpiration, - isDownloadEnabled, - isExpirationEnabled, - isPasswordEnabled, - password, - vanityName, - } = newSettings; + const { expiration, isDownloadEnabled, isExpirationEnabled, isPasswordEnabled, otpMode, password, vanityName } = + nextSettings; const convertedSettings: ConvertSharedLinkSettingsReturnType = { vanity_url: serverUrl && vanityName ? `${serverUrl}${vanityName}` : '', }; - if (!isExpirationEnabled || !newExpiration) { - if (currentExpiresAt !== null) { - convertedSettings.unshared_at = null; - } - } else { - const submittedYearMonthDay = normalizeDateToYearMonthDay(newExpiration); - const currentYearMonthDay = currentExpiresAt ? normalizeDateToYearMonthDay(currentExpiresAt) : ''; + if (prevSettings.canChangeExpiration) { + const prevExpiration = toCalendarDate(fromAbsolute(expiresAt ?? 0, getLocalTimeZone())); - if (submittedYearMonthDay !== currentYearMonthDay) { - convertedSettings.unshared_at = convertISOStringToUTCDate( - new Date(newExpiration).toISOString(), - ).toISOString(); + if (!isExpirationEnabled || !expiration) { + convertedSettings.unshared_at = null; + } else if (expiration.compare(prevExpiration) !== 0) { + convertedSettings.unshared_at = expiration.toDate(getLocalTimeZone()).toISOString(); } } // Download permissions can only be set on "company" or "open" shared links. if (accessLevel !== ACCESS_COLLAB) { const permissions: ConvertSharedLinkSettingsReturnType['permissions'] = { can_preview: !isDownloadEnabled }; - if (isDownloadAvailable) { + if (prevSettings.isDownloadAvailable) { permissions.can_download = isDownloadEnabled; } - (convertedSettings as ConvertSharedLinkSettingsReturnType).permissions = permissions; + convertedSettings.permissions = permissions; } /** + * Setting a new password and updating the OTP settings are mutually exclusive. Certain fields must be + * omitted from the payload to prevent the backend from resetting or validating properties. + * * This block covers the following cases: - * - Setting a new password: "isPasswordEnabled" is true, and "password" is a non-empty string. - * - Removing a password: "isPasswordEnabled" is false, and "password" is an empty string. - * The API only accepts non-empty strings and null values, so the empty string must be converted to null. * - * Other notes: - * - Passwords can only be set on "open" shared links. - * - Attempting to set the password field on any other type of shared link will throw a 400 error. - * - When other settings are updated, and a password has already been set, the SharedLinkSettingsModal - * returns password = '' and isPasswordEnabled = true. In these cases, the password should *not* - * be converted to null, because that would remove the existing password. + * - If a password is unchanged, the `password` and `one_time_passcode` fields must be omitted + * - If a password is removed, the `password` field must be set to `null` + * - If a password is updated, the `one_time_passcode` field must be omitted + * - If the OTP mode is unchanged, the `one_time_passcode` field must be omitted + * - If the OTP mode is updated, the `password` field must be omitted or set to `null` */ if (accessLevel === ACCESS_OPEN) { - if (isPasswordEnabled && !!password) { - convertedSettings.password = password; - } else if (!isPasswordEnabled) { + if (!isPasswordEnabled) { convertedSettings.password = null; + + if (prevSettings.isOtpAvailable && prevSettings.otpMode !== otpMode) { + convertedSettings.one_time_passcode = { + mode: otpMode, + ...(otpMode === OTP_MODE_ALLOWLIST ? { allowlist: [] } : null), + }; + } + } else if (password) { + convertedSettings.password = password; } } diff --git a/yarn.lock b/yarn.lock index 2670c140c5..7dd762e12d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1154,10 +1154,10 @@ resolved "https://registry.yarnpkg.com/@box/content-field/-/content-field-3.1.17.tgz#9e054382059a5f0c0cc6bac0909de5d88d06516e" integrity sha512-/ltjqRT6CpE4wSdlTUE2Ye0cbfTfVdcWiD23PBFXYdZPhfPP+Eq9G9HgeByxGh68w7JfTMD5LAYTd/Edxg0WAw== -"@box/copy-input@^1.42.16": - version "1.42.16" - resolved "https://registry.yarnpkg.com/@box/copy-input/-/copy-input-1.42.16.tgz#a5eb28e07d7296fc7277dae96fda73de96b1dfba" - integrity sha512-QDjB0MjzYE1IeRFn4C8NPakLhNq/PULcmDgWTbcku2ch69IRTz4GkR488LPfi31K9Pj94dUF+qMYGM2Fe3JEYA== +"@box/copy-input@^2.3.21": + version "2.3.21" + resolved "https://registry.yarnpkg.com/@box/copy-input/-/copy-input-2.3.21.tgz#8833130c0bf40f64199c90db9a86d859fe7681d3" + integrity sha512-RugUMMEQbMsGirjH8A2H25KnlaGCQb1zwwJCf+xzKhm1itLT73A5frXbFWKW/zlA8Hyqw3QTkMq7DWpWVO4B3Q== "@box/frontend@^11.0.1": version "11.0.1" @@ -1258,20 +1258,20 @@ resolved "https://registry.yarnpkg.com/@box/types/-/types-2.1.8.tgz#8ff5972f2fc0ea80e511dbcfcdc76dce72f8891f" integrity sha512-Cj869wVrXRH0Z5gz5hOlmGciJqbWaEuZcbL4Jd09gF9igBbzmGzfVfiuOgas1940oXwXuTUnQzTyf5mJ646UiQ== -"@box/unified-share-modal@^3.5.25": - version "3.5.25" - resolved "https://registry.yarnpkg.com/@box/unified-share-modal/-/unified-share-modal-3.5.25.tgz#0a6a4864de076248179f7ac5515e21e16f06a93d" - integrity sha512-kt2v9v2ggBn5Y2fJ80sAJEgUguVcJuPfiT4+YPEYeCk3YkxkdjH5ythOShXqsM53f7hcc2hZP7fFQtE3H7ZxmQ== +"@box/unified-share-modal@^3.6.2": + version "3.6.2" + resolved "https://registry.yarnpkg.com/@box/unified-share-modal/-/unified-share-modal-3.6.2.tgz#b7b4b71b56371529ed5786b0a281569e300f60ea" + integrity sha512-XVmrAdQhW22UhyVrEvWaHE75JM6KjAjsJKE7bsUzepAshrc7aGy8G3i/oNKQgfyE6KGucoANX4pVeGEXVLAvJg== "@box/uploads-manager@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@box/uploads-manager/-/uploads-manager-2.4.0.tgz#c336bc4baef6265fa5a75d266dedd8784532f1eb" integrity sha512-8tXfla+FNnKoqLnUSs+CLc2EEIueOJhGvJFhVtnJ8YOKGvoBSC3eiZA9iSScJQ4/OjK0ImqBlCsuLOAReCflfw== -"@box/user-selector@3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@box/user-selector/-/user-selector-3.0.1.tgz#229b92c7c5150cc0090a6cbe15f75d31a00a65b7" - integrity sha512-JqgBAwkvTLuw1Gch0YwEIU5rYrJX7ZF0IQGKzHJFcNvoUqjZAsEApzFCELNO10JP7lmMWNdbDCrwHV7UXSExcA== +"@box/user-selector@^3.1.21": + version "3.1.21" + resolved "https://registry.yarnpkg.com/@box/user-selector/-/user-selector-3.1.21.tgz#2b52a16f0fb9983b03b7f1bdcc4db2dc8ffb9b1d" + integrity sha512-5PbmAk3niUzE8VJXdhq5PklhN5DzBpXkoHuRZ3uQfJN9ikWeXd/QQUzLISVEHwIfC22TAAx3cTQpXdbFIP1yKg== "@cacheable/memory@^2.0.8": version "2.0.8"