From 0afc6ca0098e4f94ea331e4d9b7356467b436390 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 26 May 2026 16:41:21 +0200 Subject: [PATCH 1/2] feat(share): add logic and its coverage --- package.json | 2 +- .../hooks/useAccessRequests.test.ts | 195 ++++++++++++++++++ .../ShareDialog/hooks/useAccessRequests.ts | 69 +++++++ src/app/i18n/locales/de.json | 5 +- src/app/i18n/locales/en.json | 5 +- src/app/i18n/locales/es.json | 5 +- src/app/i18n/locales/fr.json | 5 +- src/app/i18n/locales/it.json | 5 +- src/app/i18n/locales/ru.json | 5 +- src/app/i18n/locales/tw.json | 5 +- src/app/i18n/locales/zh.json | 5 +- src/app/share/services/share.service.test.ts | 70 +++++++ src/app/share/services/share.service.ts | 46 ++++- src/app/share/types/index.ts | 13 +- .../store/slices/sharedLinks/index.test.ts | 84 +++++++- src/app/store/slices/sharedLinks/index.ts | 39 +++- src/testUtils/fixtures/drive.fixtures.ts | 7 + src/testUtils/fixtures/users.fixtures.ts | 33 +++ yarn.lock | 8 +- 19 files changed, 586 insertions(+), 20 deletions(-) create mode 100644 src/app/drive/components/ShareDialog/hooks/useAccessRequests.test.ts create mode 100644 src/app/drive/components/ShareDialog/hooks/useAccessRequests.ts create mode 100644 src/testUtils/fixtures/users.fixtures.ts diff --git a/package.json b/package.json index d65006e6e8..b183fac932 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "=1.16.4", + "@internxt/sdk": "=1.17.1", "@internxt/ui": "=0.1.15", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/src/app/drive/components/ShareDialog/hooks/useAccessRequests.test.ts b/src/app/drive/components/ShareDialog/hooks/useAccessRequests.test.ts new file mode 100644 index 0000000000..4ed9cffe45 --- /dev/null +++ b/src/app/drive/components/ShareDialog/hooks/useAccessRequests.test.ts @@ -0,0 +1,195 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { useAccessRequests } from './useAccessRequests'; +import { useAppDispatch } from 'app/store/hooks'; +import shareService from 'app/share/services/share.service'; +import { localStorageService, errorService } from 'services'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { aes, stringUtils } from '@internxt/lib'; +import { sharedActions } from 'app/store/slices/sharedLinks'; +import { getUser } from 'testUtils/fixtures/users.fixtures'; +import { getCastedError } from 'testUtils/fixtures/drive.fixtures'; + +vi.mock('app/store/hooks', () => ({ + useAppDispatch: vi.fn(), +})); + +vi.mock('i18next', () => ({ + t: (key: string) => key, +})); + +vi.mock('app/share/services/share.service', () => ({ + default: { + acceptSharedFolderInvite: vi.fn(), + declineSharedFolderInvite: vi.fn(), + }, +})); + +vi.mock('services', () => ({ + localStorageService: { getUser: vi.fn() }, + errorService: { castError: vi.fn() }, +})); + +vi.mock('app/notifications/services/notifications.service', () => ({ + default: { show: vi.fn() }, + ToastType: { Error: 'error', Success: 'success' }, +})); + +vi.mock('@internxt/lib', () => ({ + aes: { encrypt: vi.fn() }, + stringUtils: { generateRandomStringUrlSafe: vi.fn() }, +})); + +vi.mock('app/store/slices/sharedLinks', () => ({ + sharedActions: { popAccessRequest: vi.fn() }, +})); + +describe('Access Requests - Custom Hook', () => { + const mockDispatch = vi.fn(); + const mockPopAccessRequestAction = { type: 'shared/popAccessRequest', payload: '' }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAppDispatch).mockReturnValue(mockDispatch); + vi.spyOn(sharedActions, 'popAccessRequest').mockReturnValue(mockPopAccessRequestAction as any); + }); + + describe('Accepting the access request', () => { + it('When a user accepts a folder access request, then the mnemonic is encrypted and the invite is accepted with the correct payload', async () => { + const invitationId = 'invite-111'; + const roleId = 'role-editor'; + const user = getUser(); + const randomCode = 'rnd8code'; + const encryptedMnemonic = 'encrypted-mnemonic-value'; + + vi.spyOn(localStorageService, 'getUser').mockReturnValue(user as any); + const generateRandomStringSpy = vi.spyOn(stringUtils, 'generateRandomStringUrlSafe').mockReturnValue(randomCode); + const aesEncryptSpy = vi.spyOn(aes, 'encrypt').mockReturnValue(encryptedMnemonic); + const acceptSharedFolderInviteSpy = vi + .spyOn(shareService, 'acceptSharedFolderInvite') + .mockResolvedValue(undefined as any); + + const { result } = renderHook(() => useAccessRequests()); + + await result.current.onAcceptAccessRequest(invitationId, { roleId }); + + expect(generateRandomStringSpy).toHaveBeenCalledWith(8); + expect(aesEncryptSpy).toHaveBeenCalledWith(user.mnemonic, randomCode); + expect(acceptSharedFolderInviteSpy).toHaveBeenCalledWith({ + invitationId, + acceptInvite: { + encryptionKey: encryptedMnemonic, + encryptionAlgorithm: 'inxt-v2', + roleId, + }, + }); + }); + + it('When a folder access request is accepted successfully, then the invitation is removed from the pending list', async () => { + const invitationId = 'invite-222'; + const roleId = 'role-viewer'; + + vi.spyOn(localStorageService, 'getUser').mockReturnValue(getUser() as any); + vi.spyOn(stringUtils, 'generateRandomStringUrlSafe').mockReturnValue('abcd1234'); + vi.spyOn(aes, 'encrypt').mockReturnValue('enc-mnemonic'); + vi.spyOn(shareService, 'acceptSharedFolderInvite').mockResolvedValue(undefined as any); + const popAccessRequestSpy = vi + .spyOn(sharedActions, 'popAccessRequest') + .mockReturnValue(mockPopAccessRequestAction as any); + + const { result } = renderHook(() => useAccessRequests()); + + await result.current.onAcceptAccessRequest(invitationId, { roleId }); + + expect(popAccessRequestSpy).toHaveBeenCalledWith(invitationId); + expect(mockDispatch).toHaveBeenCalledWith(mockPopAccessRequestAction); + }); + + it('When the share service rejects while accepting a request, then an error notification is displayed and the invitation is NOT removed from the list', async () => { + const invitationId = 'invite-333'; + const roleId = 'role-owner'; + const networkError = new Error('Network failure'); + const castedError = getCastedError({ requestId: 'req-999' }); + + vi.spyOn(localStorageService, 'getUser').mockReturnValue(getUser() as any); + vi.spyOn(stringUtils, 'generateRandomStringUrlSafe').mockReturnValue('xy8zcode'); + vi.spyOn(aes, 'encrypt').mockReturnValue('enc-mnemonic'); + vi.spyOn(shareService, 'acceptSharedFolderInvite').mockRejectedValue(networkError); + const castErrorSpy = vi.spyOn(errorService, 'castError').mockReturnValue(castedError as any); + const notificationServiceSpy = vi.spyOn(notificationsService, 'show'); + const popAccessRequestSpy = vi + .spyOn(sharedActions, 'popAccessRequest') + .mockReturnValue(mockPopAccessRequestAction as any); + + const { result } = renderHook(() => useAccessRequests()); + + await result.current.onAcceptAccessRequest(invitationId, { roleId }); + + expect(castErrorSpy).toHaveBeenCalledWith(networkError); + expect(notificationServiceSpy).toHaveBeenCalledWith({ + text: 'notificationMessages.errorAcceptingAccessRequest', + type: ToastType.Error, + requestId: castedError.requestId, + }); + expect(popAccessRequestSpy).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe('Declining the access request', () => { + it('When a user declines a folder access request, then the decline is sent to the share service with the correct invitation ID', async () => { + const invitationId = 'invite-444'; + + const declineSharedFolderInviteSpy = vi + .spyOn(shareService, 'declineSharedFolderInvite') + .mockResolvedValue(undefined as any); + + const { result } = renderHook(() => useAccessRequests()); + + await result.current.onDeclineAccessRequest(invitationId); + + expect(declineSharedFolderInviteSpy).toHaveBeenCalledWith({ invitationId }); + }); + + it('When a folder access request is declined successfully, then the invitation is removed from the pending list', async () => { + const invitationId = 'invite-555'; + + vi.spyOn(shareService, 'declineSharedFolderInvite').mockResolvedValue(undefined as any); + const popAccessRequestSpy = vi + .spyOn(sharedActions, 'popAccessRequest') + .mockReturnValue(mockPopAccessRequestAction as any); + + const { result } = renderHook(() => useAccessRequests()); + + await result.current.onDeclineAccessRequest(invitationId); + + expect(popAccessRequestSpy).toHaveBeenCalledWith(invitationId); + expect(mockDispatch).toHaveBeenCalledWith(mockPopAccessRequestAction); + }); + + it('When the share service rejects while declining a request, then an error notification is displayed and the invitation is NOT removed from the list', async () => { + const invitationId = 'invite-666'; + const networkError = new Error('Service unavailable'); + const castedError = getCastedError({ requestId: 'req-777' }); + + vi.spyOn(shareService, 'declineSharedFolderInvite').mockRejectedValue(networkError); + const notificationServiceSpy = vi.spyOn(notificationsService, 'show'); + const castErrorSpy = vi.spyOn(errorService, 'castError').mockReturnValue(castedError as any); + const popAccessRequestSpy = vi.spyOn(sharedActions, 'popAccessRequest'); + + const { result } = renderHook(() => useAccessRequests()); + + await result.current.onDeclineAccessRequest(invitationId); + + expect(castErrorSpy).toHaveBeenCalledWith(networkError); + expect(notificationServiceSpy).toHaveBeenCalledWith({ + text: 'notificationMessages.errorDecliningAccessRequest', + type: ToastType.Error, + requestId: castedError.requestId, + }); + expect(popAccessRequestSpy).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/drive/components/ShareDialog/hooks/useAccessRequests.ts b/src/app/drive/components/ShareDialog/hooks/useAccessRequests.ts new file mode 100644 index 0000000000..2f1ce7c2d3 --- /dev/null +++ b/src/app/drive/components/ShareDialog/hooks/useAccessRequests.ts @@ -0,0 +1,69 @@ +import { aes, stringUtils } from '@internxt/lib'; +import { AcceptInvitationToSharedFolderPayload } from '@internxt/sdk/dist/drive/share/types'; +import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import shareService from 'app/share/services/share.service'; +import { useAppDispatch } from 'app/store/hooks'; +import { sharedActions } from 'app/store/slices/sharedLinks'; +import { t } from 'i18next'; +import { errorService, localStorageService } from 'services'; + +export const useAccessRequests = () => { + const dispatch = useAppDispatch(); + const removeAccessRequestFromList = (invitationId: string) => { + dispatch(sharedActions.popAccessRequest(invitationId)); + }; + + const onAcceptAccessRequest = async ( + invitationId: string, + payload: Pick, + ) => { + try { + const user = localStorageService.getUser() as UserSettings; + const { mnemonic } = user; + const code = stringUtils.generateRandomStringUrlSafe(8); + const encryptedMnemonic = aes.encrypt(mnemonic, code); + + await shareService.acceptSharedFolderInvite({ + invitationId: invitationId, + acceptInvite: { + encryptionKey: encryptedMnemonic, + encryptionAlgorithm: 'inxt-v2', + roleId: payload.roleId, + }, + }); + + removeAccessRequestFromList(invitationId); + } catch (error) { + const castedError = errorService.castError(error); + console.error('[ACCESS REQUEST] Error while accepting an access request: ', castedError); + notificationsService.show({ + text: t('notificationMessages.errorAcceptingAccessRequest'), + type: ToastType.Error, + requestId: castedError.requestId, + }); + } + }; + + const onDeclineAccessRequest = async (invitationId: string): Promise => { + try { + await shareService.declineSharedFolderInvite({ + invitationId, + }); + removeAccessRequestFromList(invitationId); + } catch (error) { + const castedError = errorService.castError(error); + console.error('[ACCESS REQUEST] Error while declining an access request: ', castedError); + notificationsService.show({ + text: t('notificationMessages.errorDecliningAccessRequest'), + type: ToastType.Error, + requestId: castedError.requestId, + }); + } + }; + + return { + onAcceptAccessRequest, + onDeclineAccessRequest, + }; +}; diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 0559563e57..652f826b78 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1483,7 +1483,10 @@ "errorAcceptingWorkspaceInvitation": "Beim Annehmen der Einladung ist ein Fehler aufgetreten", "errorWhileUpdatingWorkspaceMembers": "Beim Aktualisieren der Mitglieder ist ein Fehler aufgetreten", "errorWhileFetchingCurrentWorkspaceMembers": "Beim Abrufen der Mitglieder des Arbeitsbereichs ist ein Fehler aufgetreten", - "newMembersCannotBeLessThanTheExistentOnesError": "Die ausgewählten Mitglieder dürfen nicht weniger sein als die aktuellen Mitglieder des Arbeitsbereichs" + "newMembersCannotBeLessThanTheExistentOnesError": "Die ausgewählten Mitglieder dürfen nicht weniger sein als die aktuellen Mitglieder des Arbeitsbereichs", + "errorAcceptingAccessRequest": "Beim Akzeptieren der Zugriffsanfrage ist ein Fehler aufgetreten", + "errorDecliningAccessRequest": "Beim Ablehnen der Zugriffsanfrage ist ein Fehler aufgetreten", + "errorGettingAccessRequests": "Beim Abrufen der Zugriffsanfragen ist ein Fehler aufgetreten" }, "actions": { "undo": "Rückgängig machen", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index e56edce0e1..cc30154f1b 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1564,7 +1564,10 @@ "errorAcceptingWorkspaceInvitation": "Something went wrong while accepting the invitation", "errorWhileUpdatingWorkspaceMembers": "Something went wrong while updating the members", "errorWhileFetchingCurrentWorkspaceMembers": "Something went wrong while fetching workspace members", - "newMembersCannotBeLessThanTheExistentOnesError": "Selected members cannot be fewer than current workspace members" + "newMembersCannotBeLessThanTheExistentOnesError": "Selected members cannot be fewer than current workspace members", + "errorAcceptingAccessRequest": "Something went wrong while accepting the access request", + "errorDecliningAccessRequest": "Something went wrong while declining the access request", + "errorGettingAccessRequests": "Something went wrong while getting access requests" }, "actions": { "undo": "Undo", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 4f39691f77..d9b4a6f8cb 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1527,7 +1527,10 @@ "errorAcceptingWorkspaceInvitation": "Ocurrió un error al aceptar la invitación", "errorWhileUpdatingWorkspaceMembers": "Ocurrió un error al actualizar los miembros", "errorWhileFetchingCurrentWorkspaceMembers": "Ocurrió un error al obtener los miembros del espacio de trabajo", - "newMembersCannotBeLessThanTheExistentOnesError": "Los miembros seleccionados no pueden ser menos que los miembros actuales del espacio de trabajo" + "newMembersCannotBeLessThanTheExistentOnesError": "Los miembros seleccionados no pueden ser menos que los miembros actuales del espacio de trabajo", + "errorAcceptingAccessRequest": "Algo salió mal al aceptar la solicitud de acceso", + "errorDecliningAccessRequest": "Algo salió mal al rechazar la solicitud de acceso", + "errorGettingAccessRequests": "Algo salió mal al obtener las solicitudes de acceso" }, "success": { "passwordChanged": "Contraseña actualizada correctamente", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index f8f5cadba7..ff57ce1fe7 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1488,7 +1488,10 @@ "errorAcceptingWorkspaceInvitation": "Une erreur s'est produite lors de l'acceptation de l'invitation", "errorWhileUpdatingWorkspaceMembers": "Une erreur s'est produite lors de la mise à jour des membres", "errorWhileFetchingCurrentWorkspaceMembers": "Une erreur s'est produite lors de la récupération des membres de l'espace de travail", - "newMembersCannotBeLessThanTheExistentOnesError": "Les membres sélectionnés ne peuvent pas être inférieurs au nombre de membres actuels de l'espace de travail" + "newMembersCannotBeLessThanTheExistentOnesError": "Les membres sélectionnés ne peuvent pas être inférieurs au nombre de membres actuels de l'espace de travail", + "errorAcceptingAccessRequest": "Une erreur s'est produite lors de l'acceptation de la demande d'accès", + "errorDecliningAccessRequest": "Une erreur s'est produite lors du refus de la demande d'accès", + "errorGettingAccessRequests": "Une erreur s'est produite lors de la récupération des demandes d'accès" }, "actions": { "undo": "Annuler", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 18bc7c1414..d751603f28 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1595,7 +1595,10 @@ "errorAcceptingWorkspaceInvitation": "Si è verificato un errore durante l'accettazione dell'invito", "errorWhileUpdatingWorkspaceMembers": "Si è verificato un errore durante l'aggiornamento dei membri", "errorWhileFetchingCurrentWorkspaceMembers": "Si è verificato un errore durante il recupero dei membri dello spazio di lavoro", - "newMembersCannotBeLessThanTheExistentOnesError": "I membri selezionati non possono essere meno dei membri attuali dello spazio di lavoro" + "newMembersCannotBeLessThanTheExistentOnesError": "I membri selezionati non possono essere meno dei membri attuali dello spazio di lavoro", + "errorAcceptingAccessRequest": "Si è verificato un errore durante l'accettazione della richiesta di accesso", + "errorDecliningAccessRequest": "Si è verificato un errore durante il rifiuto della richiesta di accesso", + "errorGettingAccessRequests": "Si è verificato un errore durante il recupero delle richieste di accesso" }, "actions": { "undo": "Annulla", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 010c398f5f..cd1024298c 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1503,7 +1503,10 @@ "errorAcceptingWorkspaceInvitation": "Произошла ошибка при принятии приглашения", "errorWhileUpdatingWorkspaceMembers": "Произошла ошибка при обновлении участников", "errorWhileFetchingCurrentWorkspaceMembers": "Произошла ошибка при получении участников рабочего пространства", - "newMembersCannotBeLessThanTheExistentOnesError": "Выбранных участников не может быть меньше, чем текущих участников рабочего пространства" + "newMembersCannotBeLessThanTheExistentOnesError": "Выбранных участников не может быть меньше, чем текущих участников рабочего пространства", + "errorAcceptingAccessRequest": "Произошла ошибка при принятии запроса на доступ", + "errorDecliningAccessRequest": "Произошла ошибка при отклонении запроса на доступ", + "errorGettingAccessRequests": "Произошла ошибка при получении запросов на доступ" }, "actions": { "undo": "Отменить", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index a1fd9ea979..71e78f4fce 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1494,7 +1494,10 @@ "errorAcceptingWorkspaceInvitation": "接受邀請時出錯", "errorWhileUpdatingWorkspaceMembers": "更新成員時出了點問題", "errorWhileFetchingCurrentWorkspaceMembers": "獲取工作區成員時出了點問題", - "newMembersCannotBeLessThanTheExistentOnesError": "選擇的成員數量不能少於當前工作區的成員數量。" + "newMembersCannotBeLessThanTheExistentOnesError": "選擇的成員數量不能少於當前工作區的成員數量。", + "errorAcceptingAccessRequest": "接受存取請求時發生錯誤", + "errorDecliningAccessRequest": "拒絕存取請求時發生錯誤", + "errorGettingAccessRequests": "獲取存取請求時發生錯誤" }, "actions": { "undo": "撤銷", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index d16002a64b..65b21b88e5 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1529,7 +1529,10 @@ "errorAcceptingWorkspaceInvitation": "接受邀请时出错", "errorWhileUpdatingWorkspaceMembers": "更新成员时出了点问题", "errorWhileFetchingCurrentWorkspaceMembers": "获取工作区成员时出了点问题", - "newMembersCannotBeLessThanTheExistentOnesError": "选择的成员数量不能少于当前工作区的成员数量。" + "newMembersCannotBeLessThanTheExistentOnesError": "选择的成员数量不能少于当前工作区的成员数量。", + "errorAcceptingAccessRequest": "接受访问请求时发生错误", + "errorDecliningAccessRequest": "拒绝访问请求时发生错误", + "errorGettingAccessRequests": "获取访问请求时发生错误" }, "actions": { "undo": "撤销", diff --git a/src/app/share/services/share.service.test.ts b/src/app/share/services/share.service.test.ts index 8a8d10d95d..1eb10a2832 100644 --- a/src/app/share/services/share.service.test.ts +++ b/src/app/share/services/share.service.test.ts @@ -299,6 +299,76 @@ describe('Inviting user to Shared Folder', () => { }); }); +describe('Request access to shared folder', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('When a user requests access to a shared item, then a captcha is generated and the request includes the user email', async () => { + const { generateCaptchaToken } = await import('utils'); + const { SdkFactory } = await import('../../core/factory/sdk'); + const { requestAccessToSharedFolder } = await import('./share.service'); + + vi.mocked(localStorageService.getUser).mockReturnValue({ email: 'user@example.com' } as any); + + const requestAccessToSharedFolderFn = vi.fn().mockResolvedValue(undefined); + const createShareClientFn = vi.fn(() => ({ requestUserToSharedFolder: requestAccessToSharedFolderFn })); + vi.mocked(SdkFactory.getNewApiInstance).mockReturnValue({ createShareClient: createShareClientFn } as any); + + await requestAccessToSharedFolder({ uuid: 'item-uuid', itemType: 'folder', notificationMessage: 'Hello' }); + + expect(generateCaptchaToken).toHaveBeenCalledTimes(1); + expect(createShareClientFn).toHaveBeenCalledWith('mocked-captcha-token'); + expect(requestAccessToSharedFolderFn).toHaveBeenCalledWith({ + itemType: 'folder', + itemId: 'item-uuid', + notifyUser: true, + roleId: '', + sharedWith: 'user@example.com', + notificationMessage: 'Hello', + }); + }); +}); + +describe('Get Access Request Invitations', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('When getting access request invitations for an item, then the item identifier and type are included in the call', async () => { + const { SdkFactory } = await import('../../core/factory/sdk'); + const { getAccessRequestInvitations } = await import('./share.service'); + + const mockRequests = [{ id: 'req-1', itemId: 'item-uuid' }]; + const getSharedFolderInvitationsFn = vi.fn().mockResolvedValue(mockRequests); + const createShareClientFn = vi.fn(() => ({ getSharedFolderInvitations: getSharedFolderInvitationsFn })); + vi.mocked(SdkFactory.getNewApiInstance).mockReturnValue({ createShareClient: createShareClientFn } as any); + + const result = await getAccessRequestInvitations('item-uuid', 'folder'); + + expect(createShareClientFn).toHaveBeenCalledWith(); + expect(getSharedFolderInvitationsFn).toHaveBeenCalledWith({ itemId: 'item-uuid', itemType: 'folder' }); + expect(result).toEqual(mockRequests); + }); + + test('When getting access request invitations fails, then the error is handled and propagated to the caller', async () => { + const { SdkFactory } = await import('../../core/factory/sdk'); + const { getAccessRequestInvitations } = await import('./share.service'); + const errorService = (await import('services/error.service')).default; + + const originalError = new Error('API Error'); + const getSharedFolderInvitationsFn = vi.fn().mockRejectedValue(originalError); + vi.mocked(SdkFactory.getNewApiInstance).mockReturnValue({ + createShareClient: vi.fn(() => ({ getSharedFolderInvitations: getSharedFolderInvitationsFn })), + } as any); + + await expect(getAccessRequestInvitations('item-uuid', 'folder')).rejects.toEqual( + expect.objectContaining({ message: originalError.message }), + ); + expect(errorService.castError).toHaveBeenCalledWith(originalError); + }); +}); + describe('Get shared link', () => { test('When an error occurs fetching a shared link, then a notification is shown', async () => { vi.spyOn(shareService, 'createPublicSharingItem').mockRejectedValue(new Error('Unexpected error')); diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index ab4639d9fd..a4ba6a4206 100644 --- a/src/app/share/services/share.service.ts +++ b/src/app/share/services/share.service.ts @@ -33,7 +33,7 @@ import { downloadFolderAsZip } from 'app/drive/services/folder.service'; import { DriveFolderData } from 'app/drive/types'; import { DownloadManager } from '../../network/DownloadManager'; import notificationsService, { ToastType } from '../../notifications/services/notifications.service'; -import { AdvancedSharedItem } from '../types'; +import { AccessRequest, AdvancedSharedItem } from '../types'; import { domainManager } from './DomainManager'; import { generateCaptchaToken } from 'utils'; import { copyTextToClipboard } from 'utils/copyToClipboard.utils'; @@ -168,6 +168,46 @@ export async function inviteUserToSharedFolder(props: ShareFolderWithUserPayload }); } +export const requestAccessToSharedFolder = async ({ + uuid, + itemType, + notificationMessage, +}: { + uuid: string; + itemType: 'folder' | 'file'; + notificationMessage?: string; +}): Promise => { + const user = localStorageService.getUser() as UserSettings; + + const captchaToken = await generateCaptchaToken(); + const shareClient = SdkFactory.getNewApiInstance().createShareClient(captchaToken); + + return shareClient.requestUserToSharedFolder({ + itemType, + itemId: uuid, + notifyUser: true, + roleId: '', + sharedWith: user.email, + notificationMessage, + }); +}; + +export const getAccessRequestInvitations = async ( + itemId: string, + itemType: 'file' | 'folder', +): Promise => { + const shareClient = SdkFactory.getNewApiInstance().createShareClient(); + + return shareClient + .getSharedFolderInvitations({ + itemId, + itemType, + }) + .catch((error) => { + throw errorService.castError(error); + }); +}; + export function getUsersOfSharedFolder({ itemType, folderId, @@ -207,7 +247,7 @@ export function declineSharedFolderInvite({ }); } -export function acceptSharedFolderInvite({ +export async function acceptSharedFolderInvite({ invitationId, acceptInvite, token, @@ -843,9 +883,11 @@ const shareService = { validateSharingInvitation, getPublicSharedItemInfo, getSharedFolderSize, + requestAccessToSharedFolder, inviteUserToSharedFolder, getSharedFolderInvitationsAsInvitedUser, getSharingRoles, + getAccessRequestInvitations, }; export default shareService; diff --git a/src/app/share/types/index.ts b/src/app/share/types/index.ts index 5452deddcc..bb60b00f57 100644 --- a/src/app/share/types/index.ts +++ b/src/app/share/types/index.ts @@ -1,4 +1,4 @@ -import { SharedFiles, SharedFolders } from '@internxt/sdk/dist/drive/share/types'; +import { SharedFiles, SharedFolders, SharingInvite } from '@internxt/sdk/dist/drive/share/types'; import { NetworkCredentials } from '../../network/download'; import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; @@ -36,3 +36,14 @@ export enum UserRoles { Reader = 'reader', Owner = 'owner', } + +// TODO: Add invited objet to SharingInvite in SDK +export type AccessRequest = SharingInvite & { + invited: { + avatar: string | null; + email: string; + lastname?: string; + name: string; + uuid: string; + }; +}; diff --git a/src/app/store/slices/sharedLinks/index.test.ts b/src/app/store/slices/sharedLinks/index.test.ts index bfc201d88b..7324080828 100644 --- a/src/app/store/slices/sharedLinks/index.test.ts +++ b/src/app/store/slices/sharedLinks/index.test.ts @@ -13,16 +13,17 @@ import { generateNewKeys, hybridDecryptMessageWithPrivateKey, } from '../../../crypto/services/pgp.service'; -import { +import sharedReducer, { HYBRID_ALGORITHM, removeUserFromSharedFolder, + sharedActions, sharedThunks, ShareFileWithUserPayload, STANDARD_ALGORITHM, stopSharingItem, } from './index'; import notificationsService from 'app/notifications/services/notifications.service'; -const { shareItemWithUser } = sharedThunks; +const { shareItemWithUser, getAccessRequestsFromSharedItem } = sharedThunks; describe('Encryption and Decryption', () => { beforeAll(() => { @@ -36,6 +37,7 @@ describe('Encryption and Decryption', () => { getSharingRoles: vi.fn(), stopSharingItem: vi.fn(), removeUserRole: vi.fn(), + getAccessRequestInvitations: vi.fn(), }, })); vi.mock('services/user.service', () => ({ @@ -572,3 +574,81 @@ describe('Encryption and Decryption', () => { }); }); }); + +describe('Fetching access requests for a shared item', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('When access requests are retrieved successfully, then the list is saved in the state', async () => { + const mockRequests = [ + { + id: 'req-1', + itemId: 'item-uuid', + itemType: 'folder', + invited: { uuid: 'user-uuid', email: 'a@b.com', name: 'A', lastname: 'B', avatar: null }, + }, + ]; + const getAccessRequestInvitationsSpy = vi + .spyOn(shareService, 'getAccessRequestInvitations') + .mockResolvedValue(mockRequests as any); + const dispatchMock = vi.fn(); + + const thunk = getAccessRequestsFromSharedItem({ itemId: 'item-uuid', itemType: 'folder' }); + await thunk(dispatchMock, vi.fn(), undefined); + + expect(getAccessRequestInvitationsSpy).toHaveBeenCalledWith('item-uuid', 'folder'); + expect(dispatchMock).toHaveBeenCalledWith(sharedActions.setAccessRequests(mockRequests as any)); + }); + + test('When retrieving access requests fails, then an error notification is shown to the user', async () => { + const error = new Error('Network error'); + vi.spyOn(shareService, 'getAccessRequestInvitations').mockRejectedValue(error); + const showNotificationSpy = vi.spyOn(notificationsService, 'show'); + + const thunk = getAccessRequestsFromSharedItem({ itemId: 'item-uuid', itemType: 'folder' }); + await thunk(vi.fn(), vi.fn(), undefined); + + expect(showNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); + }); +}); + +describe('Access requests state management', () => { + const baseState = sharedReducer(undefined, { type: 'unknown' }); + + const makeRequest = (id: string) => ({ + id, + itemId: 'item-uuid', + itemType: 'folder' as const, + sharedWith: 'user@example.com', + encryptionKey: 'key', + encryptionAlgorithm: 'inxt-v2', + type: 'SELF' as const, + roleId: 'role-id', + createdAt: new Date(), + updatedAt: new Date(), + invited: { uuid: 'user-uuid', email: 'user@example.com', name: 'User', lastname: 'Name', avatar: null }, + }); + + test('When a new list of access requests is set, then the previous list is completely replaced', () => { + const requests = [makeRequest('req-1'), makeRequest('req-2')]; + const nextState = sharedReducer(baseState, sharedActions.setAccessRequests(requests)); + expect(nextState.accessRequests).toEqual(requests); + }); + + test('When an access request is dismissed by ID, then only that request is removed from the list', () => { + const stateWithRequests = sharedReducer( + baseState, + sharedActions.setAccessRequests([makeRequest('req-1'), makeRequest('req-2')]), + ); + const nextState = sharedReducer(stateWithRequests, sharedActions.popAccessRequest('req-1')); + expect(nextState.accessRequests).toHaveLength(1); + expect(nextState.accessRequests[0].id).toBe('req-2'); + }); + + test('When dismissing an access request with an ID that does not exist, then the list remains unchanged', () => { + const stateWithRequests = sharedReducer(baseState, sharedActions.setAccessRequests([makeRequest('req-1')])); + const nextState = sharedReducer(stateWithRequests, sharedActions.popAccessRequest('req-unknown')); + expect(nextState.accessRequests).toHaveLength(1); + }); +}); diff --git a/src/app/store/slices/sharedLinks/index.ts b/src/app/store/slices/sharedLinks/index.ts index 2b596f4df5..1e1be6f6cc 100644 --- a/src/app/store/slices/sharedLinks/index.ts +++ b/src/app/store/slices/sharedLinks/index.ts @@ -1,13 +1,13 @@ import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import shareService from 'app/share/services/share.service'; -import { RootState } from '../..'; +import { AppDispatch, RootState } from '../..'; import { Role, SharedFoldersInvitationsAsInvitedUserResponse } from '@internxt/sdk/dist/drive/share/types'; import errorService from 'services/error.service'; import navigationService from 'services/navigation.service'; import { AppView } from 'app/core/types'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { UserRoles } from 'app/share/types'; +import { AccessRequest, UserRoles } from 'app/share/types'; import referralService from 'services/referral.service'; import { t } from 'i18next'; import userService from 'services/user.service'; @@ -22,6 +22,7 @@ export interface ShareLinksState { pendingInvitations: SharedFoldersInvitationsAsInvitedUserResponse[]; currentShareId: string | null; currentSharingRole: UserRoles | null; + accessRequests: AccessRequest[]; } const initialState: ShareLinksState = { @@ -30,6 +31,7 @@ const initialState: ShareLinksState = { pendingInvitations: [], currentShareId: null, currentSharingRole: null, + accessRequests: [], }; export interface ShareFileWithUserPayload { @@ -103,6 +105,32 @@ const shareItemWithUser = createAsyncThunk( + 'shareds/getAccessRequestsFromSharedItem', + async ({ itemId, itemType }: GetAccessRequestsFromSharedItemPayload, { dispatch }) => { + try { + const accessRequests = await shareService.getAccessRequestInvitations(itemId, itemType); + dispatch(sharedActions.setAccessRequests(accessRequests)); + } catch (error) { + errorService.reportError(error); + notificationsService.show({ + text: t('notificationMessages.errorGettingAccessRequests'), + type: ToastType.Error, + }); + throw error; + } + }, +); + interface StopSharingItemPayload { itemType: string; itemId: string; @@ -211,6 +239,12 @@ export const sharedSlice = createSlice({ setCurrentSharingRole: (state: ShareLinksState, action: PayloadAction) => { state.currentSharingRole = action.payload; }, + setAccessRequests: (state: ShareLinksState, action: PayloadAction) => { + state.accessRequests = action.payload; + }, + popAccessRequest: (state: ShareLinksState, action: PayloadAction) => { + state.accessRequests = state.accessRequests.filter((item) => item.id !== action.payload); + }, }, extraReducers: (builder) => { builder @@ -243,6 +277,7 @@ export const sharedThunks = { removeUserFromSharedFolder, getSharedFolderRoles, getPendingInvitations, + getAccessRequestsFromSharedItem, }; export default sharedSlice.reducer; diff --git a/src/testUtils/fixtures/drive.fixtures.ts b/src/testUtils/fixtures/drive.fixtures.ts index 42929c8c85..ae1efed2b0 100644 --- a/src/testUtils/fixtures/drive.fixtures.ts +++ b/src/testUtils/fixtures/drive.fixtures.ts @@ -1,5 +1,12 @@ import { DriveItemData, ExceededFile, ReachedFileSizeLimitDialogInfo } from 'app/drive/types'; +// Plain Error extended with AppError fields — avoids importing @internxt/sdk whose local +// linked build lacks ESM named exports (AppError, UserType, …). +export type CastedError = Error & { requestId?: string; status?: number }; + +export const getCastedError = (overrides: Partial = {}): CastedError => + Object.assign(new Error('Something went wrong'), { requestId: 'req-id-abc', status: 500 }, overrides); + export const getExceededFile = (overrides: Partial = {}): ExceededFile => ({ name: 'large-document.pdf', size: 1073741824, diff --git a/src/testUtils/fixtures/users.fixtures.ts b/src/testUtils/fixtures/users.fixtures.ts new file mode 100644 index 0000000000..1723280ead --- /dev/null +++ b/src/testUtils/fixtures/users.fixtures.ts @@ -0,0 +1,33 @@ +import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; + +export const getUser = (overrides: Partial = {}): UserSettings => ({ + userId: 'user-id-1234', + uuid: 'user-uuid-1234', + email: 'user@example.com', + name: 'Test', + lastname: 'User', + username: 'user@example.com', + bridgeUser: 'bridge-user', + bucket: 'bucket-id', + backupsBucket: null, + root_folder_id: 1, + rootFolderId: 'root-folder-uuid', + rootFolderUuid: 'root-folder-uuid', + sharedWorkspace: false, + credit: 0, + mnemonic: 'test mnemonic phrase', + privateKey: 'private-key', + publicKey: 'public-key', + revocationKey: 'revocation-key', + keys: { + ecc: { publicKey: 'ecc-public-key', privateKey: 'ecc-private-key' }, + kyber: { publicKey: 'kyber-public-key', privateKey: 'kyber-private-key' }, + }, + appSumoDetails: null, + registerCompleted: true, + hasReferralsProgram: false, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + avatar: null, + emailVerified: true, + ...overrides, +}); diff --git a/yarn.lock b/yarn.lock index 42e74f7955..06332b202e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1955,10 +1955,10 @@ version "1.0.2" resolved "https://codeload.github.com/internxt/prettier-config/tar.gz/9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9" -"@internxt/sdk@=1.16.4": - version "1.16.4" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.16.4.tgz#1dad7c3fe1581cfb62f9720b6a6470b7c52d7f34" - integrity sha512-L4UG7SvNAlldCqx43f5EWt/oVOleqssBb67y1ZUqCcGh3h/wC9mpvkuz80lsGmkzO/ZSZZ0beUzWFlk0LmWM6g== +"@internxt/sdk@=1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.17.1.tgz#7ad5b9d078b80219c5a60160e2116049967df3a5" + integrity sha512-3lSnQxHPYXLdSnlrEN0dPPq43VryvYUFAcCxUf1Nta4tl80mMO1uROk+PU6We2EJgR4PvN3z+Rv2oZ7vD7K+NA== dependencies: axios "^1.16.0" From 1163332ff4a89511655b1fda245cb75af005c3e2 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Wed, 27 May 2026 08:56:44 +0200 Subject: [PATCH 2/2] fix: remove useless interface --- src/app/share/services/share.service.ts | 4 ++-- src/app/share/types/index.ts | 13 +------------ src/app/store/slices/sharedLinks/index.ts | 12 ++++++++---- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index a4ba6a4206..18babdd12d 100644 --- a/src/app/share/services/share.service.ts +++ b/src/app/share/services/share.service.ts @@ -33,7 +33,7 @@ import { downloadFolderAsZip } from 'app/drive/services/folder.service'; import { DriveFolderData } from 'app/drive/types'; import { DownloadManager } from '../../network/DownloadManager'; import notificationsService, { ToastType } from '../../notifications/services/notifications.service'; -import { AccessRequest, AdvancedSharedItem } from '../types'; +import { AdvancedSharedItem } from '../types'; import { domainManager } from './DomainManager'; import { generateCaptchaToken } from 'utils'; import { copyTextToClipboard } from 'utils/copyToClipboard.utils'; @@ -195,7 +195,7 @@ export const requestAccessToSharedFolder = async ({ export const getAccessRequestInvitations = async ( itemId: string, itemType: 'file' | 'folder', -): Promise => { +): Promise => { const shareClient = SdkFactory.getNewApiInstance().createShareClient(); return shareClient diff --git a/src/app/share/types/index.ts b/src/app/share/types/index.ts index bb60b00f57..5452deddcc 100644 --- a/src/app/share/types/index.ts +++ b/src/app/share/types/index.ts @@ -1,4 +1,4 @@ -import { SharedFiles, SharedFolders, SharingInvite } from '@internxt/sdk/dist/drive/share/types'; +import { SharedFiles, SharedFolders } from '@internxt/sdk/dist/drive/share/types'; import { NetworkCredentials } from '../../network/download'; import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; @@ -36,14 +36,3 @@ export enum UserRoles { Reader = 'reader', Owner = 'owner', } - -// TODO: Add invited objet to SharingInvite in SDK -export type AccessRequest = SharingInvite & { - invited: { - avatar: string | null; - email: string; - lastname?: string; - name: string; - uuid: string; - }; -}; diff --git a/src/app/store/slices/sharedLinks/index.ts b/src/app/store/slices/sharedLinks/index.ts index 1e1be6f6cc..a99225c8e4 100644 --- a/src/app/store/slices/sharedLinks/index.ts +++ b/src/app/store/slices/sharedLinks/index.ts @@ -2,12 +2,16 @@ import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import shareService from 'app/share/services/share.service'; import { AppDispatch, RootState } from '../..'; -import { Role, SharedFoldersInvitationsAsInvitedUserResponse } from '@internxt/sdk/dist/drive/share/types'; +import { + Role, + SharedFoldersInvitationsAsInvitedUserResponse, + SharingInvite, +} from '@internxt/sdk/dist/drive/share/types'; import errorService from 'services/error.service'; import navigationService from 'services/navigation.service'; import { AppView } from 'app/core/types'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { AccessRequest, UserRoles } from 'app/share/types'; +import { UserRoles } from 'app/share/types'; import referralService from 'services/referral.service'; import { t } from 'i18next'; import userService from 'services/user.service'; @@ -22,7 +26,7 @@ export interface ShareLinksState { pendingInvitations: SharedFoldersInvitationsAsInvitedUserResponse[]; currentShareId: string | null; currentSharingRole: UserRoles | null; - accessRequests: AccessRequest[]; + accessRequests: SharingInvite[]; } const initialState: ShareLinksState = { @@ -239,7 +243,7 @@ export const sharedSlice = createSlice({ setCurrentSharingRole: (state: ShareLinksState, action: PayloadAction) => { state.currentSharingRole = action.payload; }, - setAccessRequests: (state: ShareLinksState, action: PayloadAction) => { + setAccessRequests: (state: ShareLinksState, action: PayloadAction) => { state.accessRequests = action.payload; }, popAccessRequest: (state: ShareLinksState, action: PayloadAction) => {