cozy-sharing 33.3.3 → 33.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -291,6 +291,210 @@ describe('updateDocumentPermissions', () => {
291
291
  })
292
292
  })
293
293
 
294
+ describe('fetchSharedDriveSharingLinks', () => {
295
+ const PERM_DRIVE_FILE = {
296
+ type: 'io.cozy.permissions',
297
+ id: 'perm_drive_file',
298
+ attributes: {
299
+ type: 'share',
300
+ permissions: {
301
+ rule0: {
302
+ type: 'io.cozy.files',
303
+ verbs: ['GET'],
304
+ values: ['file_in_drive']
305
+ }
306
+ },
307
+ shortcodes: { code: 'shortcode123' }
308
+ }
309
+ }
310
+
311
+ const driveFile = {
312
+ _id: 'file_in_drive',
313
+ id: 'file_in_drive',
314
+ driveId: 'drive_123'
315
+ }
316
+
317
+ it('fetches shared drive sharing links by file id', async () => {
318
+ const mockFindLinksByIds = jest
319
+ .fn()
320
+ .mockResolvedValue({ data: [PERM_DRIVE_FILE] })
321
+ const mockClient = createMockClient({})
322
+ mockClient.collection = jest.fn().mockReturnValue({
323
+ findLinksByIds: mockFindLinksByIds
324
+ })
325
+
326
+ const provider = new SharingProvider({ client: mockClient })
327
+ provider.state = reducer()
328
+ provider.dispatch = jest.fn(action => {
329
+ provider.state = reducer(provider.state, action)
330
+ })
331
+
332
+ const result = await provider.fetchSharedDriveSharingLinks(driveFile)
333
+
334
+ expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.permissions', {
335
+ driveId: 'drive_123'
336
+ })
337
+ expect(mockFindLinksByIds).toHaveBeenCalledWith(['file_in_drive'])
338
+ expect(provider.dispatch).toHaveBeenCalled()
339
+ const permissions = getDocumentPermissions(provider.state, 'file_in_drive')
340
+ expect(permissions).toHaveLength(1)
341
+ expect(permissions[0].id).toBe(PERM_DRIVE_FILE.id)
342
+ expect(result).toEqual([PERM_DRIVE_FILE])
343
+ })
344
+
345
+ it('returns an empty array and skips the API call for non-shared-drive documents', async () => {
346
+ const mockClient = createMockClient({})
347
+ const findLinksByIds = jest.fn()
348
+ mockClient.collection = jest.fn().mockReturnValue({
349
+ findLinksByIds
350
+ })
351
+
352
+ const provider = new SharingProvider({ client: mockClient })
353
+ provider.dispatch = jest.fn()
354
+
355
+ const result = await provider.fetchSharedDriveSharingLinks({
356
+ _id: 'regular_file'
357
+ })
358
+
359
+ expect(result).toEqual([])
360
+ expect(findLinksByIds).not.toHaveBeenCalled()
361
+ expect(provider.dispatch).not.toHaveBeenCalled()
362
+ })
363
+
364
+ it('uses document.id as a fallback when _id is missing', async () => {
365
+ const mockFindLinksByIds = jest
366
+ .fn()
367
+ .mockResolvedValue({ data: [PERM_DRIVE_FILE] })
368
+ const mockClient = createMockClient({})
369
+ mockClient.collection = jest.fn().mockReturnValue({
370
+ findLinksByIds: mockFindLinksByIds
371
+ })
372
+
373
+ const provider = new SharingProvider({ client: mockClient })
374
+ provider.state = reducer()
375
+ provider.dispatch = jest.fn(action => {
376
+ provider.state = reducer(provider.state, action)
377
+ })
378
+
379
+ await provider.fetchSharedDriveSharingLinks({
380
+ id: 'file_in_drive',
381
+ driveId: 'drive_123'
382
+ })
383
+
384
+ expect(mockFindLinksByIds).toHaveBeenCalledWith(['file_in_drive'])
385
+ })
386
+
387
+ it('does not dispatch when the response has no data', async () => {
388
+ const mockFindLinksByIds = jest.fn().mockResolvedValue({ data: [] })
389
+ const mockClient = createMockClient({})
390
+ mockClient.collection = jest.fn().mockReturnValue({
391
+ findLinksByIds: mockFindLinksByIds
392
+ })
393
+
394
+ const provider = new SharingProvider({ client: mockClient })
395
+ provider.dispatch = jest.fn()
396
+
397
+ const result = await provider.fetchSharedDriveSharingLinks(driveFile)
398
+
399
+ expect(result).toEqual([])
400
+ expect(provider.dispatch).not.toHaveBeenCalled()
401
+ })
402
+ })
403
+
404
+ describe('shareByLink shared drive 409 recovery', () => {
405
+ const PERM_DRIVE_FILE = {
406
+ type: 'io.cozy.permissions',
407
+ id: 'perm_drive_file',
408
+ attributes: {
409
+ type: 'share',
410
+ permissions: {
411
+ rule0: {
412
+ type: 'io.cozy.files',
413
+ verbs: ['GET'],
414
+ values: ['file_in_drive']
415
+ }
416
+ },
417
+ shortcodes: { code: 'shortcode123' }
418
+ }
419
+ }
420
+
421
+ const driveFile = {
422
+ _id: 'file_in_drive',
423
+ id: 'file_in_drive',
424
+ driveId: 'drive_123'
425
+ }
426
+
427
+ it('falls back to fetching existing links when create returns 409', async () => {
428
+ const conflict = Object.assign(new Error('Conflict'), { status: 409 })
429
+ const mockCreateSharingLink = jest.fn().mockRejectedValue(conflict)
430
+ const mockFindLinksByIds = jest
431
+ .fn()
432
+ .mockResolvedValue({ data: [PERM_DRIVE_FILE] })
433
+ const mockClient = createMockClient({})
434
+ mockClient.collection = jest.fn().mockReturnValue({
435
+ createSharingLink: mockCreateSharingLink,
436
+ findLinksByIds: mockFindLinksByIds
437
+ })
438
+
439
+ const provider = new SharingProvider({ client: mockClient })
440
+ provider.state = reducer()
441
+ provider.dispatch = jest.fn(action => {
442
+ provider.state = reducer(provider.state, action)
443
+ })
444
+
445
+ const resp = await provider.shareByLink(driveFile, { verbs: ['GET'] })
446
+
447
+ expect(mockCreateSharingLink).toHaveBeenCalled()
448
+ expect(mockFindLinksByIds).toHaveBeenCalledWith(['file_in_drive'])
449
+ expect(resp.data.id).toBe(PERM_DRIVE_FILE.id)
450
+ })
451
+
452
+ it('does not duplicate existing links when 409 recovery refetches them', async () => {
453
+ const conflict = Object.assign(new Error('Conflict'), { status: 409 })
454
+ const mockCreateSharingLink = jest.fn().mockRejectedValue(conflict)
455
+ const mockFindLinksByIds = jest
456
+ .fn()
457
+ .mockResolvedValue({ data: [PERM_DRIVE_FILE] })
458
+ const mockClient = createMockClient({})
459
+ mockClient.collection = jest.fn().mockReturnValue({
460
+ createSharingLink: mockCreateSharingLink,
461
+ findLinksByIds: mockFindLinksByIds
462
+ })
463
+
464
+ const provider = new SharingProvider({ client: mockClient })
465
+ provider.state = reducer(undefined, addSharingLink(PERM_DRIVE_FILE))
466
+ provider.dispatch = jest.fn(action => {
467
+ provider.state = reducer(provider.state, action)
468
+ })
469
+
470
+ const resp = await provider.shareByLink(driveFile, { verbs: ['GET'] })
471
+
472
+ const permissions = getDocumentPermissions(provider.state, 'file_in_drive')
473
+ expect(resp.data.id).toBe(PERM_DRIVE_FILE.id)
474
+ expect(provider.dispatch).not.toHaveBeenCalled()
475
+ expect(permissions).toHaveLength(1)
476
+ })
477
+
478
+ it('rethrows the original error when 409 happens and no link is found', async () => {
479
+ const conflict = Object.assign(new Error('Conflict'), { status: 409 })
480
+ const mockCreateSharingLink = jest.fn().mockRejectedValue(conflict)
481
+ const mockFindLinksByIds = jest.fn().mockResolvedValue({ data: [] })
482
+ const mockClient = createMockClient({})
483
+ mockClient.collection = jest.fn().mockReturnValue({
484
+ createSharingLink: mockCreateSharingLink,
485
+ findLinksByIds: mockFindLinksByIds
486
+ })
487
+
488
+ const provider = new SharingProvider({ client: mockClient })
489
+ provider.state = reducer()
490
+ provider.dispatch = jest.fn()
491
+
492
+ await expect(
493
+ provider.shareByLink(driveFile, { verbs: ['GET'] })
494
+ ).rejects.toBe(conflict)
495
+ })
496
+ })
497
+
294
498
  describe('updateSharingMemberType', () => {
295
499
  const mockSharing = {
296
500
  id: 'sharing-123',
@@ -1,7 +1,8 @@
1
1
  import PropTypes from 'prop-types'
2
- import React, { useCallback, useState } from 'react'
2
+ import React, { useCallback, useEffect, useState } from 'react'
3
3
 
4
4
  import { useClient } from 'cozy-client'
5
+ import minilog from 'cozy-minilog'
5
6
  import Button from 'cozy-ui/transpiled/react/Buttons'
6
7
  import { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
7
8
  import Typography from 'cozy-ui/transpiled/react/Typography'
@@ -21,6 +22,8 @@ import { default as DumbShareByEmail } from '../ShareByEmail'
21
22
  import ShareByLink from '../ShareByLink'
22
23
  import WhoHasAccess from '../WhoHasAccess'
23
24
 
25
+ const log = minilog('FederatedFolderModal')
26
+
24
27
  const FederatedFolderModalContent = ({
25
28
  onClose,
26
29
  document: existingDocument,
@@ -34,6 +37,8 @@ const FederatedFolderModalContent = ({
34
37
  getSharingById,
35
38
  getSharingLink,
36
39
  getFederatedShareLink,
40
+ getDocumentPermissions,
41
+ fetchSharedDriveSharingLinks,
37
42
  getRecipients,
38
43
  hasSharedChild,
39
44
  hasSharedParent,
@@ -56,6 +61,9 @@ const FederatedFolderModalContent = ({
56
61
  // in members before the modal closes. That's why when clicking on "Share"
57
62
  // we do not use the reactive existingDocument and existingRecipients.
58
63
  const [isSending, setIsSending] = useState(false)
64
+ const [isFetchingSharingLinks, setIsFetchingSharingLinks] = useState(false)
65
+ const [fetchedSharingLinksDocumentId, setFetchedSharingLinksDocumentId] =
66
+ useState(null)
59
67
  const [frozenDoc, setFrozenDoc] = useState(null)
60
68
  const [frozenRecipients, setFrozenRecipients] = useState(null)
61
69
 
@@ -169,13 +177,46 @@ const FederatedFolderModalContent = ({
169
177
  }
170
178
  }
171
179
 
172
- const existingRecipients = existingDocument
173
- ? getRecipients(existingDocument._id)
180
+ const documentId = existingDocument?._id || existingDocument?.id
181
+ const existingRecipients = documentId ? getRecipients(documentId) : []
182
+ const documentPermissions = documentId
183
+ ? getDocumentPermissions(documentId)
174
184
  : []
175
185
  const displayedLink = existingDocument?.driveId
176
186
  ? getFederatedShareLink(existingDocument)
177
187
  : getSharingLink(existingDocument?._id)
178
188
 
189
+ useEffect(() => {
190
+ if (!existingDocument?.driveId) return
191
+ if (!documentId) return
192
+ if (!fetchSharedDriveSharingLinks) return
193
+ if (documentPermissions.length > 0) return
194
+ if (isFetchingSharingLinks) return
195
+ if (fetchedSharingLinksDocumentId === documentId) return
196
+
197
+ const fetchSharingLinks = async () => {
198
+ setIsFetchingSharingLinks(true)
199
+
200
+ try {
201
+ await fetchSharedDriveSharingLinks(existingDocument)
202
+ } catch (error) {
203
+ log.error('Failed to fetch shared drive sharing links', error)
204
+ } finally {
205
+ setFetchedSharingLinksDocumentId(documentId)
206
+ setIsFetchingSharingLinks(false)
207
+ }
208
+ }
209
+
210
+ fetchSharingLinks()
211
+ }, [
212
+ documentId,
213
+ existingDocument,
214
+ documentPermissions.length,
215
+ fetchedSharingLinksDocumentId,
216
+ fetchSharedDriveSharingLinks,
217
+ isFetchingSharingLinks
218
+ ])
219
+
179
220
  const modalTitle = t('FederatedFolder.shareTitle', { name: folderName })
180
221
 
181
222
  return (
@@ -17,6 +17,8 @@ const mockGetRecipients = jest.fn().mockReturnValue([])
17
17
  const mockGetSharingById = jest.fn()
18
18
  const mockHasSharedChild = jest.fn()
19
19
  const mockHasSharedParent = jest.fn()
20
+ const mockFetchSharedDriveSharingLinks = jest.fn()
21
+ const mockGetDocumentPermissions = jest.fn().mockReturnValue([])
20
22
  const mockShowAlert = jest.fn()
21
23
  const mockOnClose = jest.fn()
22
24
 
@@ -27,7 +29,8 @@ jest.mock('../../hooks/useSharingContext', () => ({
27
29
  revoke: mockRevoke,
28
30
  getSharingLink: mockGetSharingLink,
29
31
  getFederatedShareLink: mockGetFederatedShareLink,
30
- getDocumentPermissions: jest.fn().mockReturnValue([]),
32
+ getDocumentPermissions: mockGetDocumentPermissions,
33
+ fetchSharedDriveSharingLinks: mockFetchSharedDriveSharingLinks,
31
34
  getOwner: jest.fn(),
32
35
  getRecipients: mockGetRecipients,
33
36
  getSharingById: mockGetSharingById,
@@ -65,7 +68,8 @@ const createSharingContextValue = () => ({
65
68
  hasWriteAccess: jest.fn(),
66
69
  getRecipients: jest.fn(),
67
70
  getSharingLink: mockGetSharingLink,
68
- getDocumentPermissions: jest.fn().mockReturnValue([]),
71
+ getDocumentPermissions: mockGetDocumentPermissions,
72
+ fetchSharedDriveSharingLinks: mockFetchSharedDriveSharingLinks,
69
73
  share: mockShare
70
74
  })
71
75
 
@@ -96,6 +100,8 @@ describe('FederatedFolderModal', () => {
96
100
 
97
101
  beforeEach(() => {
98
102
  jest.clearAllMocks()
103
+ mockGetDocumentPermissions.mockReturnValue([])
104
+ mockFetchSharedDriveSharingLinks.mockResolvedValue([])
99
105
  mockGetSharingLink.mockReturnValue('https://example.com/share/abc123')
100
106
  mockGetFederatedShareLink.mockReturnValue(
101
107
  'https://example.com/share/federated-abc123'
@@ -227,6 +233,85 @@ describe('FederatedFolderModal', () => {
227
233
  })
228
234
  })
229
235
 
236
+ describe('shared drive link fetch on mount', () => {
237
+ it('should fetch shared drive sharing links when document has a driveId and no permissions yet', async () => {
238
+ mockGetDocumentPermissions.mockReturnValue([])
239
+
240
+ setup({
241
+ document: { ...mockDocument, driveId: 'federated-folder-id' }
242
+ })
243
+
244
+ await waitFor(() => {
245
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledTimes(1)
246
+ })
247
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledWith(
248
+ expect.objectContaining({
249
+ _id: mockDocument._id,
250
+ driveId: 'federated-folder-id'
251
+ })
252
+ )
253
+ })
254
+
255
+ it('should use document.id when _id is missing', async () => {
256
+ const document = {
257
+ id: 'folder-id-only',
258
+ name: 'My Test Folder',
259
+ path: '/test/folder',
260
+ driveId: 'federated-folder-id'
261
+ }
262
+
263
+ setup({ document })
264
+
265
+ await waitFor(() => {
266
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledWith(document)
267
+ })
268
+ expect(mockGetDocumentPermissions).toHaveBeenCalledWith('folder-id-only')
269
+ })
270
+
271
+ it('should not fetch shared drive sharing links when document has no driveId', async () => {
272
+ const { findByText } = setup({ document: mockDocument })
273
+
274
+ await findByText('Copy link')
275
+
276
+ expect(mockFetchSharedDriveSharingLinks).not.toHaveBeenCalled()
277
+ })
278
+
279
+ it('should not refetch after one attempt when permissions stay empty', async () => {
280
+ const document = { ...mockDocument, driveId: 'federated-folder-id' }
281
+ const { rerender } = setup({ document })
282
+
283
+ await waitFor(() => {
284
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledTimes(1)
285
+ })
286
+
287
+ rerender(
288
+ <AppLike client={client} sharingContextValue={sharingContextValue}>
289
+ <FederatedFolderModal document={document} onClose={mockOnClose} />
290
+ </AppLike>
291
+ )
292
+
293
+ expect(mockFetchSharedDriveSharingLinks).toHaveBeenCalledTimes(1)
294
+ })
295
+
296
+ it('should not fetch shared drive sharing links when permissions are already loaded', async () => {
297
+ mockGetDocumentPermissions.mockReturnValue([
298
+ {
299
+ id: 'perm-1',
300
+ type: 'io.cozy.permissions',
301
+ attributes: { shortcodes: { code: 'abc' } }
302
+ }
303
+ ])
304
+
305
+ const { findByText } = setup({
306
+ document: { ...mockDocument, driveId: 'federated-folder-id' }
307
+ })
308
+
309
+ await findByText('Copy link')
310
+
311
+ expect(mockFetchSharedDriveSharingLinks).not.toHaveBeenCalled()
312
+ })
313
+ })
314
+
230
315
  describe('onSend callback', () => {
231
316
  const pendingRecipient = { name: 'Alice', email: 'alice@example.com' }
232
317
 
@@ -22,7 +22,7 @@ export const ShareButton = ({ label, onClick, className, ...props }) => (
22
22
  export const SharedByMeButton = ({ label, onClick, className, ...props }) => (
23
23
  <Button
24
24
  data-test-id="share-by-me-button"
25
- className={classNames(styles['coz-btn-shared'], className)}
25
+ className={className}
26
26
  onClick={() => onClick()}
27
27
  icon={<Icon icon={ShareIcon} />}
28
28
  label={label}
@@ -4,11 +4,6 @@
4
4
  color: var(--primaryContrastTextColor)
5
5
  transition all .2s ease-out
6
6
 
7
- &:focus
8
- &:not([disabled]):not([aria-disabled=true]):hover
9
- border-color: var(--malachite)
10
- background-color: var(--malachite)
11
-
12
7
  .coz-btn-sharedWithMe
13
8
  border-color: #B449E7
14
9
  background-color: #B449E7