diff --git a/package.json b/package.json index 2b3ecb04d..9c5982bd8 100644 --- a/package.json +++ b/package.json @@ -173,4 +173,4 @@ "prettier --write" ] } -} +} \ No newline at end of file diff --git a/src/app/core/services/validation.service.ts b/src/app/core/services/validation.service.ts index 39aab00b7..714febcfb 100644 --- a/src/app/core/services/validation.service.ts +++ b/src/app/core/services/validation.service.ts @@ -12,9 +12,16 @@ const validateSearchText = (value: string): boolean => { return alphanumericDotsAndSpaces.test(value); }; +const validatePasswordInput = (value: string): boolean => { + const latinAlphabetAndSymbols = /^[a-zA-Z0-9ñÑ ~`!@#$%^&*()_\-+={[}\]|\\:;"'<,>.?/]*$/gm; + + return latinAlphabetAndSymbols.test(value); +}; + const validationService = { validate2FA, validateSearchText, + validatePasswordInput, }; export default validationService; diff --git a/src/app/drive/components/ShareDialog/ShareDialog.tsx b/src/app/drive/components/ShareDialog/ShareDialog.tsx index 35392ed8e..fba9d7b62 100644 --- a/src/app/drive/components/ShareDialog/ShareDialog.tsx +++ b/src/app/drive/components/ShareDialog/ShareDialog.tsx @@ -8,7 +8,18 @@ import Button from 'app/shared/components/Button/Button'; import Modal from 'app/shared/components/Modal'; import ShareInviteDialog from '../ShareInviteDialog/ShareInviteDialog'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { ArrowLeft, CaretDown, Check, CheckCircle, Globe, Link, UserPlus, Users, X } from '@phosphor-icons/react'; +import { + ArrowLeft, + CaretDown, + Check, + CheckCircle, + Globe, + Link, + Question, + UserPlus, + Users, + X, +} from '@phosphor-icons/react'; import Avatar from 'app/shared/components/Avatar'; import Spinner from 'app/shared/components/Spinner/Spinner'; import { sharedThunks } from '../../../store/slices/sharedLinks'; @@ -23,6 +34,12 @@ import { AdvancedSharedItem } from '../../../share/types'; import { DriveItemData } from '../../types'; import { TrackingPlan } from '../../../analytics/TrackingPlan'; import { trackPublicShared } from '../../../analytics/services/analytics.service'; +import BaseCheckbox from 'app/shared/components/forms/BaseCheckbox/BaseCheckbox'; +import { SharePasswordDisableDialog } from 'app/share/components/SharePasswordDisableDialog/SharePasswordDisableDialog'; +import { SharingMeta } from '@internxt/sdk/dist/drive/share/types'; +import { SharePasswordInputDialog } from 'app/share/components/SharePasswordInputDialog/SharePasswordInputDialog'; +import { Tooltip } from 'react-tooltip'; +import { DELAY_SHOW_MS } from 'app/shared/components/Tooltip/Tooltip'; type AccessMode = 'public' | 'restricted'; type UserRole = 'owner' | 'editor' | 'reader'; @@ -95,6 +112,10 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => { const [isLoading, setIsLoading] = useState(false); const [invitedUsers, setInvitedUsers] = useState([]); const [currentUserFolderRole, setCurrentUserFolderRole] = useState(''); + const [isPasswordProtected, setIsPasswordProtected] = useState(false); + const [openPasswordInput, setOpenPasswordInput] = useState(false); + const [openPasswordDisableDialog, setOpenPasswordDisableDialog] = useState(false); + const [sharingMeta, setSharingMeta] = useState(null); const [accessRequests, setAccessRequests] = useState([]); const [userOptionsEmail, setUserOptionsEmail] = useState(); @@ -118,6 +139,8 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => { setUserOptionsEmail(undefined); setUserOptionsY(0); setView('general'); + setIsPasswordProtected(false); + setSharingMeta(null); }; useEffect(() => { @@ -135,8 +158,8 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => { useEffect(() => { if (roles.length === 0) dispatch(sharedThunks.getSharedFolderRoles()); - if (roles.length > 0) loadShareInfo(); - }, [roles]); + if (roles.length > 0 && isOpen) loadShareInfo(); + }, [roles, isOpen]); useEffect(() => { const removeDeniedRequests = () => { @@ -175,20 +198,26 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => { }, [itemToShare, roles]); const loadShareInfo = async () => { + if (!itemToShare?.item) return; + setIsLoading(true); // Change object type of itemToShare to AdvancedSharedItem let shareAccessMode: AccessMode = 'public'; let sharingType = 'public'; + let isAlreadyPasswordProtected = false; - if (props.isDriveItem) { - sharingType = (itemToShare?.item as DriveItemData & { sharings: { type: string; id: string }[] }).sharings?.[0] - ?.type; - } else { - const itemType = itemToShare?.item.isFolder ? 'folder' : 'file'; - const itemId = itemToShare?.item.uuid ?? ''; + const itemType = itemToShare?.item.isFolder ? 'folder' : 'file'; + const itemId = itemToShare?.item.uuid ?? ''; + + const isItemNotSharedYet = + !isAdvanchedShareItem(itemToShare?.item) && !itemToShare.item.sharings?.length && !sharingMeta; + + if (!isItemNotSharedYet) { try { const sharingData = await shareService.getSharingType(itemId, itemType); sharingType = sharingData.type; + isAlreadyPasswordProtected = sharingData.encryptedPassword !== null; + setSharingMeta(sharingData); } catch (error) { errorService.reportError(error); } @@ -198,8 +227,7 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => { shareAccessMode = 'restricted'; } setAccessMode(shareAccessMode); - - if (!itemToShare?.item) return; + setIsPasswordProtected(isAlreadyPasswordProtected); try { await getAndUpdateInvitedUsers(); @@ -275,11 +303,14 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => { trackPublicShared(trackingPublicSharedProperties); const encryptionKey = isAdvanchedShareItem(itemToShare.item) ? itemToShare?.item?.encryptionKey : undefined; - await shareService.getPublicShareLink( + const sharingInfo = await shareService.getPublicShareLink( itemToShare?.item.uuid, itemToShare.item.isFolder ? 'folder' : 'file', encryptionKey, ); + if (sharingInfo) { + setSharingMeta(sharingInfo); + } props.onShareItem?.(); closeSelectedUserPopover(); } @@ -308,6 +339,51 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => { closeSelectedUserPopover(); }; + const onPasswordCheckboxChange = useCallback(() => { + if (!isPasswordProtected) { + setOpenPasswordInput(true); + } else { + setOpenPasswordDisableDialog(true); + } + }, [isPasswordProtected]); + + const onSavePublicSharePassword = useCallback( + async (plainPassword: string) => { + try { + let sharingInfo = sharingMeta; + + if (!sharingInfo?.encryptedCode) { + const itemType = itemToShare?.item.isFolder ? 'folder' : 'file'; + const itemId = itemToShare?.item.uuid ?? ''; + sharingInfo = await shareService.createPublicShareFromOwnerUser(itemId, itemType, plainPassword); + setSharingMeta(sharingInfo); + } else { + await shareService.saveSharingPassword(sharingInfo.id, plainPassword, sharingInfo.encryptedCode); + } + + setIsPasswordProtected(true); + } catch (error) { + errorService.castError(error); + } finally { + setOpenPasswordInput(false); + } + }, + [sharingMeta, itemToShare], + ); + + const onDisablePassword = useCallback(async () => { + try { + if (sharingMeta) { + await shareService.removeSharingPassword(sharingMeta.id); + setIsPasswordProtected(false); + } + } catch (error) { + errorService.castError(error); + } finally { + setOpenPasswordDisableDialog(false); + } + }, [sharingMeta]); + const changeAccess = async (mode: AccessMode) => { closeSelectedUserPopover(); if (mode != accessMode) { @@ -319,7 +395,9 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => { await shareService.updateSharingType(itemId, itemType, sharingType); if (sharingType === 'public') { - await shareService.createPublicShareFromOwnerUser(itemId, itemType); + const shareInfo = await shareService.createPublicShareFromOwnerUser(itemId, itemType); + setSharingMeta(shareInfo); + setIsPasswordProtected(false); } setAccessMode(mode); } catch (error) { @@ -494,6 +572,34 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => {
+ {accessMode === 'public' && !isLoading && isUserOwner && ( +
+
+
+ +

+ {translate('modals.shareModal.protectSharingModal.protect')} +

+ + +

+ {translate('modals.shareModal.protectSharingModal.protectTooltipText')} +

+
+
+
+ {isPasswordProtected && ( + + )} +
+ )}

{translate('modals.shareModal.general.generalAccess')}

@@ -603,6 +709,18 @@ const ShareDialog = (props: ShareDialogProps): JSX.Element => {
+ setOpenPasswordInput(false)} + isOpen={openPasswordInput} + onSavePassword={onSavePublicSharePassword} + isAlreadyProtected={isPasswordProtected} + /> + setOpenPasswordDisableDialog(false)} + onConfirmHandler={onDisablePassword} + /> + {/* Stop sharing confirmation dialog */} void; + onConfirmHandler: () => Promise | void; +}; + +export const SharePasswordDisableDialog = ({ + isOpen, + onClose, + onConfirmHandler, +}: SharePasswordDisableWarningDialogProps) => { + const { translate } = useTranslationContext(); + const [isLoading, setIsLoading] = useState(false); + + const handleConfirm = async () => { + setIsLoading(true); + await onConfirmHandler(); + setIsLoading(false); + }; + + return ( + +
+

+ {translate('modals.shareModal.protectSharingModal.disablePasswordTitle')} +

+

{translate('modals.shareModal.protectSharingModal.disablePasswordBody')}

+ +
+ + +
+
+
+ ); +}; diff --git a/src/app/share/components/SharePasswordInputDialog/SharePasswordInputDialog.tsx b/src/app/share/components/SharePasswordInputDialog/SharePasswordInputDialog.tsx new file mode 100644 index 000000000..8c4f5fd5a --- /dev/null +++ b/src/app/share/components/SharePasswordInputDialog/SharePasswordInputDialog.tsx @@ -0,0 +1,62 @@ +import Button from 'app/shared/components/Button/Button'; +import Modal from 'app/shared/components/Modal'; +import { useState } from 'react'; +import { Spinner } from '@phosphor-icons/react'; +import Input from 'app/shared/components/Input'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import validationService from 'app/core/services/validation.service'; + +type SharePasswordInputDialogProps = { + isOpen: boolean; + onClose: () => void; + onSavePassword: (password: string) => Promise | void; + isAlreadyProtected?: boolean; +}; + +const MAX_PASSWORD_LENGTH = 50; + +export const SharePasswordInputDialog = ({ + isOpen, + onClose, + onSavePassword, + isAlreadyProtected = true, +}: SharePasswordInputDialogProps) => { + const { translate } = useTranslationContext(); + const [isLoading, setIsLoading] = useState(false); + const [password, setPassword] = useState(''); + + const handleConfirm = async () => { + setIsLoading(true); + await onSavePassword(password); + setIsLoading(false); + }; + + return ( + +

+ {!isAlreadyProtected + ? translate('modals.shareModal.protectSharingModal.protect') + : translate('modals.shareModal.protectSharingModal.editPasswordTitle')} +

+ { + if (value.length <= MAX_PASSWORD_LENGTH && validationService.validatePasswordInput(value)) { + setPassword(value); + } + }} + value={password} + variant="password" + autoComplete="off" + /> +
+ + +
+
+ ); +}; diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index 587603f92..db0421739 100644 --- a/src/app/share/services/share.service.ts +++ b/src/app/share/services/share.service.ts @@ -19,6 +19,7 @@ import { SharedFoldersInvitationsAsInvitedUserResponse, CreateSharingPayload, SharingMeta, + PublicSharedItemInfo, } from '@internxt/sdk/dist/drive/share/types'; import { domainManager } from './DomainManager'; import _ from 'lodash'; @@ -333,6 +334,13 @@ export function getUserRoleOfSharedRolder(sharingId: string): Promise { }); } +export function getPublicSharedItemInfo(sharingId: string): Promise { + const shareClient = SdkFactory.getNewApiInstance().createShareClient(); + return shareClient.getPublicSharedItemInfo(sharingId).catch((error) => { + throw errorService.castError(error); + }); +} + export function updateUserRoleOfSharedFolder({ newRoleId, sharingId, @@ -371,6 +379,7 @@ export function stopSharingItem(itemType: string, itemId: string): Promise export const createPublicShareFromOwnerUser = async ( uuid: string, itemType: 'folder' | 'file', + plainPassword?: string, encryptionAlgorithm?: string, ): Promise => { const user = localStorageService.getUser() as UserSettings; @@ -379,6 +388,7 @@ export const createPublicShareFromOwnerUser = async ( const encryptedMnemonic = aes.encrypt(mnemonic, code); const encryptedCode = aes.encrypt(code, mnemonic); + const encryptedPassword = plainPassword ? aes.encrypt(plainPassword, code) : null; return createPublicSharingItem({ encryptionAlgorithm: encryptionAlgorithm ?? 'inxt-v2', @@ -387,14 +397,21 @@ export const createPublicShareFromOwnerUser = async ( itemId: uuid, encryptedCode, persistPreviousSharing: true, + ...(encryptedPassword && { encryptedPassword }), }); }; +export const decryptPublicSharingCodeWithOwner = (encryptedCode: string) => { + const user = localStorageService.getUser() as UserSettings; + const { mnemonic } = user; + return aes.decrypt(encryptedCode, mnemonic); +}; + export const getPublicShareLink = async ( uuid: string, itemType: 'folder' | 'file', encriptedMnemonic?: string, -): Promise => { +): Promise => { const user = localStorageService.getUser() as UserSettings; let { mnemonic } = user; const code = crypto.randomBytes(32).toString('hex'); @@ -418,6 +435,7 @@ export const getPublicShareLink = async ( if (!isCopied) throw Error('Error copying shared public link'); notificationsService.show({ text: t('shared-links.toast.copy-to-clipboard'), type: ToastType.Success }); + return publicSharingItemData; } catch (error) { notificationsService.show({ text: t('modals.shareModal.errors.copy-to-clipboard'), @@ -827,6 +845,27 @@ export function getSharingType(itemId: string, itemType: 'file' | 'folder'): Pro }); } +export function saveSharingPassword( + sharingId: string, + plainPassword: string, + encryptedCode: string, +): Promise { + const code = shareService.decryptPublicSharingCodeWithOwner(encryptedCode); + const encryptedPassword = aes.encrypt(plainPassword, code); + + const shareClient = SdkFactory.getNewApiInstance().createShareClient(); + return shareClient.saveSharingPassword(sharingId, encryptedPassword).catch((error) => { + throw errorService.castError(error); + }); +} + +export function removeSharingPassword(sharingId: string): Promise { + const shareClient = SdkFactory.getNewApiInstance().createShareClient(); + return shareClient.removeSharingPassword(sharingId).catch((error) => { + throw errorService.castError(error); + }); +} + const shareService = { createShare, createShareLink, @@ -862,7 +901,11 @@ const shareService = { getPublicSharingMeta, getPublicSharedFolderContent, getPublicShareLink, + saveSharingPassword, + removeSharingPassword, + decryptPublicSharingCodeWithOwner, validateSharingInvitation, + getPublicSharedItemInfo, }; export default shareService; diff --git a/src/app/share/types/index.ts b/src/app/share/types/index.ts index 8fabd99e5..f1f6d7e35 100644 --- a/src/app/share/types/index.ts +++ b/src/app/share/types/index.ts @@ -9,6 +9,7 @@ export type AdvancedSharedItem = SharedFolders & credentials: SharedNetworkCredentials; sharingId?: string; sharingType: 'public' | 'private'; + encryptedPassword: string | null; }; export type SharedNetworkCredentials = { diff --git a/src/app/share/views/ShareView/ShareFileView.tsx b/src/app/share/views/ShareView/ShareFileView.tsx index 4e8fc0b78..998045b81 100644 --- a/src/app/share/views/ShareView/ShareFileView.tsx +++ b/src/app/share/views/ShareView/ShareFileView.tsx @@ -27,7 +27,7 @@ import SendBanner from './SendBanner'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { ShareTypes } from '@internxt/sdk/dist/drive'; import errorService from 'app/core/services/error.service'; -import { SharingMeta } from '@internxt/sdk/dist/drive/share/types'; +import { PublicSharedItemInfo, SharingMeta } from '@internxt/sdk/dist/drive/share/types'; import Button from '../../../shared/components/Button/Button'; export interface ShareViewProps extends ShareViewState { @@ -61,6 +61,7 @@ export default function ShareFileView(props: ShareViewProps): JSX.Element { const [blobProgress, setBlobProgress] = useState(TaskProgress.Min); const [isDownloading, setIsDownloading] = useState(false); const [info, setInfo] = useState>({}); + const [itemData, setItemData] = useState(); const [isLoaded, setIsLoaded] = useState(false); const [isError, setIsError] = useState(false); const [openPreview, setOpenPreview] = useState(false); @@ -133,16 +134,25 @@ export default function ShareFileView(props: ShareViewProps): JSX.Element { name: res.item.plainName, }); }) - .catch((err) => { + .catch(async (err) => { if (err.message === 'Forbidden') { + await getSharedItemInfo(sharingId); setRequiresPassword(true); setIsLoaded(true); } - throw err; }); } + const getSharedItemInfo = async (id: string) => { + try { + const itemData = await shareService.getPublicSharedItemInfo(id); + setItemData(itemData); + } catch (error) { + errorService.reportError(error); + } + }; + function getBlob(abortController: AbortController): Promise { const fileInfo = info as unknown as ShareTypes.ShareLink; @@ -253,7 +263,12 @@ export default function ShareFileView(props: ShareViewProps): JSX.Element { const FileIcon = iconService.getItemIcon(false, info?.item?.type); body = requiresPassword ? ( - + ) : ( <> {/* File info */} diff --git a/src/app/share/views/ShareView/ShareFolderView.tsx b/src/app/share/views/ShareView/ShareFolderView.tsx index 8348f1a83..664529942 100644 --- a/src/app/share/views/ShareView/ShareFolderView.tsx +++ b/src/app/share/views/ShareView/ShareFolderView.tsx @@ -17,7 +17,8 @@ import UilImport from '@iconscout/react-unicons/icons/uil-import'; import './ShareView.scss'; import { ShareTypes } from '@internxt/sdk/dist/drive'; import Spinner from '../../../shared/components/Spinner/Spinner'; -import { SharingMeta } from '@internxt/sdk/dist/drive/share/types'; +import { PublicSharedItemInfo, SharingMeta } from '@internxt/sdk/dist/drive/share/types'; +import shareService from 'app/share/services/share.service'; import { loadWritableStreamPonyfill } from 'app/network/download'; import ShareItemPwdView from './ShareItemPwdView'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; @@ -50,6 +51,7 @@ export default function ShareFolderView(props: ShareViewProps): JSX.Element { const [progress, setProgress] = useState(TaskProgress.Min); const [isDownloading, setIsDownloading] = useState(false); const [info, setInfo] = useState>({}); + const [itemData, setItemData] = useState(); const [size, setSize] = useState(null); const [isLoaded, setIsLoaded] = useState(false); const [isError, setIsError] = useState(false); @@ -103,7 +105,7 @@ export default function ShareFolderView(props: ShareViewProps): JSX.Element { throw new Error(CHROME_IOS_ERROR_MESSAGE); } - return getPublicSharingMeta(sharingId, code) + return getPublicSharingMeta(sharingId, code, password) .then((res) => { setInfo({ ...res }); setIsLoaded(true); @@ -115,8 +117,9 @@ export default function ShareFolderView(props: ShareViewProps): JSX.Element { .then((folderSize) => { setSize(folderSize); }) - .catch((err) => { + .catch(async (err) => { if (err.message === 'Forbidden') { + await getSharedFolderInfo(sharingId); setRequiresPassword(true); setIsLoaded(true); } @@ -124,6 +127,15 @@ export default function ShareFolderView(props: ShareViewProps): JSX.Element { }); } + const getSharedFolderInfo = async (id: string) => { + try { + const itemData = await shareService.getPublicSharedItemInfo(id); + setItemData(itemData); + } catch (error) { + errorService.reportError(error); + } + }; + const loadSize = (shareId: number, folderId: number): Promise => { return getSharedFolderSize(shareId.toString(), folderId.toString()); }; @@ -238,6 +250,7 @@ export default function ShareFolderView(props: ShareViewProps): JSX.Element { onPasswordSubmitted={loadFolderInfo} itemPassword={itemPassword} setItemPassword={setItemPassword} + itemData={itemData} /> ) : ( //WITHOUT PASSWORD diff --git a/src/app/share/views/ShareView/ShareItemPwdView.tsx b/src/app/share/views/ShareView/ShareItemPwdView.tsx index f191bedc3..a594b7533 100644 --- a/src/app/share/views/ShareView/ShareItemPwdView.tsx +++ b/src/app/share/views/ShareView/ShareItemPwdView.tsx @@ -5,31 +5,63 @@ import { ReactComponent as LockLogo } from 'assets/icons/Lock.svg'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import errorService from 'app/core/services/error.service'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import iconService from 'app/drive/services/icon.service'; +import sizeService from 'app/drive/services/size.service'; +import transformItemService from 'app/drive/services/item-transform.service'; +import { DriveItemData } from 'app/drive/types'; +import AppError from 'app/core/types'; +import Button from 'app/shared/components/Button/Button'; +import validationService from 'app/core/services/validation.service'; export interface ShareItemPwdViewProps { onPasswordSubmitted: (password: string) => Promise; itemPassword: string; setItemPassword: (password: string) => void; + itemData?: { plainName: string; size: number; type?: string }; } const ShareItemPwdView = (props: ShareItemPwdViewProps) => { const { translate } = useTranslationContext(); const { onPasswordSubmitted, setItemPassword, itemPassword } = props; const [onPasswordError, setOnPasswordError] = useState(false); - - if (!onPasswordError) { - setTimeout(() => setOnPasswordError(false), 6000); - } + const [isSubmitting, setIsSubmitting] = useState(false); + const Icon = props.itemData ? iconService.getItemIcon(props.itemData.type === 'folder', props.itemData.type) : null; function handleChange(pwd) { const value = pwd.target.value; - setItemPassword(value); + if (validationService.validatePasswordInput(value)) { + setItemPassword(value); + } } + const handlePasswordSubmit = async () => { + try { + setIsSubmitting(true); + const encodedPassword = encodeURIComponent(itemPassword); + await onPasswordSubmitted(encodedPassword); + } catch (error) { + if (error instanceof AppError) { + const { statusCode } = JSON.parse(error.message); + if (statusCode === 403) { + setOnPasswordError(true); + } else { + errorService.reportError(error); + notificationsService.show({ + text: errorService.castError(error).message, + type: ToastType.Warning, + duration: 50000, + }); + } + } + } finally { + setIsSubmitting(false); + } + }; + return ( -
+
{/*
*/} -
+

{translate('shareItemPwdView.title')}

@@ -38,8 +70,25 @@ const ShareItemPwdView = (props: ShareItemPwdViewProps) => { {translate('shareItemPwdView.putPwd1')}

+ {props.itemData && Icon !== null && ( +
+
+
+ +
+
+
+
+ {transformItemService.getItemPlainNameWithExtension(props.itemData as DriveItemData)} +
+
+ {sizeService.bytesToString(props.itemData?.size ?? 0)} +
+
+
+ )} {/*
*/} -
+

{translate('shareItemPwdView.password')}

{

{translate('error.wrongPassword')}

)} - + {translate('shareItemPwdView.access')} +
);