cozy-sharing 33.3.0 → 33.3.2

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [33.3.2](https://github.com/cozy/cozy-libs/compare/cozy-sharing@33.3.1...cozy-sharing@33.3.2) (2026-06-11)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **cozy-sharing:** Use readonly endpoint in updateSharingMemberType ([f4914b6](https://github.com/cozy/cozy-libs/commit/f4914b61daaf5c20b17c4b9f1ddda511860a56ac))
11
+
12
+ ## [33.3.1](https://github.com/cozy/cozy-libs/compare/cozy-sharing@33.3.0...cozy-sharing@33.3.1) (2026-06-10)
13
+
14
+ ### Bug Fixes
15
+
16
+ - **cozy-sharing:** Expire share links at the end of the selected day ([db81ed9](https://github.com/cozy/cozy-libs/commit/db81ed936a15850f278404511c1b6faec0fc7a54))
17
+
6
18
  # [33.3.0](https://github.com/cozy/cozy-libs/compare/cozy-sharing@33.2.2...cozy-sharing@33.3.0) (2026-06-10)
7
19
 
8
20
  ### Features
@@ -471,7 +471,7 @@ export var SharingProvider = /*#__PURE__*/function (_Component) {
471
471
  */
472
472
  _defineProperty(_this2, "updateSharingMemberType", /*#__PURE__*/function () {
473
473
  var _ref12 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee10(sharingId, memberIndex, newType) {
474
- var sharing, member, matchedContacts, contact, readOnly;
474
+ var sharing, member, makeReadOnly;
475
475
  return _regeneratorRuntime.wrap(function _callee10$(_context10) {
476
476
  while (1) switch (_context10.prev = _context10.next) {
477
477
  case 0:
@@ -495,62 +495,49 @@ export var SharingProvider = /*#__PURE__*/function (_Component) {
495
495
  throw new Error('Member not found');
496
496
 
497
497
  case 6:
498
- _context10.next = 8;
499
- return _this2.props.client.collection('io.cozy.contacts').find({
500
- email: {
501
- $elemMatch: {
502
- address: member.email
503
- }
504
- },
505
- _id: {
506
- $gt: null
507
- }
508
- }, {
509
- indexedFields: ['_id']
510
- });
498
+ makeReadOnly = newType === 'one-way';
511
499
 
512
- case 8:
513
- matchedContacts = _context10.sent;
514
-
515
- if (matchedContacts.data.length) {
516
- _context10.next = 11;
500
+ if (!(newType !== 'one-way' && newType !== 'two-way')) {
501
+ _context10.next = 9;
517
502
  break;
518
503
  }
519
504
 
520
- throw new Error("Contact not found for member ".concat(member.email));
505
+ throw new Error('Unsupported sharing type: ' + newType);
521
506
 
522
- case 11:
523
- contact = matchedContacts.data[0]; // Revoke (API call only, no state dispatch to avoid UI flickering)
507
+ case 9:
508
+ _context10.prev = 9;
524
509
 
525
- _context10.next = 14;
526
- return _this2.sharingCol.revokeRecipient(sharing, memberIndex);
510
+ if (!makeReadOnly) {
511
+ _context10.next = 15;
512
+ break;
513
+ }
527
514
 
528
- case 14:
529
- // Re-add with the new permission type
530
- readOnly = newType === 'one-way';
531
- _context10.prev = 15;
532
- _context10.next = 18;
533
- return _this2.addRecipients({
534
- document: sharing,
535
- recipients: readOnly ? [] : [contact],
536
- readOnlyRecipients: readOnly ? [contact] : []
537
- });
515
+ _context10.next = 13;
516
+ return _this2.sharingCol.setReadOnly(sharing, memberIndex);
538
517
 
539
- case 18:
540
- _context10.next = 24;
518
+ case 13:
519
+ _context10.next = 17;
541
520
  break;
542
521
 
543
- case 20:
544
- _context10.prev = 20;
545
- _context10.t0 = _context10["catch"](15);
546
- log.error("Failed to re-add member ".concat(member.email, " after revocation. The member has been revoked but could not be re-added with the new permission type."), _context10.t0);
522
+ case 15:
523
+ _context10.next = 17;
524
+ return _this2.sharingCol.setReadWrite(sharing, memberIndex);
525
+
526
+ case 17:
527
+ _context10.next = 23;
528
+ break;
529
+
530
+ case 19:
531
+ _context10.prev = 19;
532
+ _context10.t0 = _context10["catch"](9);
533
+ log.error("Failed to change member ".concat(member.email, " permission type to ").concat(newType), _context10.t0);
547
534
  throw _context10.t0;
548
535
 
549
- case 24:
536
+ case 23:
550
537
  case "end":
551
538
  return _context10.stop();
552
539
  }
553
- }, _callee10, null, [[15, 20]]);
540
+ }, _callee10, null, [[9, 19]]);
554
541
  }));
555
542
 
556
543
  return function (_x16, _x17, _x18) {
@@ -412,12 +412,6 @@ describe('updateDocumentPermissions', function () {
412
412
  })));
413
413
  });
414
414
  describe('updateSharingMemberType', function () {
415
- var mockContact = {
416
- _id: 'contact-1',
417
- email: [{
418
- address: 'bob@bob.cozy'
419
- }]
420
- };
421
415
  var mockSharing = {
422
416
  id: 'sharing-123',
423
417
  type: 'io.cozy.sharings',
@@ -432,20 +426,14 @@ describe('updateSharingMemberType', function () {
432
426
  }
433
427
  };
434
428
  var instance;
435
- var mockContactsCollection;
436
429
  beforeEach(function () {
437
- mockContactsCollection = {
438
- find: jest.fn().mockResolvedValue({
439
- data: [mockContact]
440
- })
441
- };
442
430
  var mockClient = {
443
431
  getStackClient: function getStackClient() {
444
432
  return {
445
433
  uri: 'http://cozy.local'
446
434
  };
447
435
  },
448
- collection: jest.fn().mockReturnValue(mockContactsCollection)
436
+ collection: jest.fn().mockReturnValue({})
449
437
  };
450
438
  instance = new SharingProvider({
451
439
  client: mockClient
@@ -454,9 +442,10 @@ describe('updateSharingMemberType', function () {
454
442
  sharings: [mockSharing]
455
443
  });
456
444
  instance.sharingCol = {
457
- revokeRecipient: jest.fn().mockResolvedValue({})
445
+ setReadOnly: jest.fn().mockResolvedValue({}),
446
+ setReadWrite: jest.fn().mockResolvedValue({})
458
447
  };
459
- jest.spyOn(instance, 'addRecipients').mockResolvedValue({});
448
+ jest.spyOn(instance, 'setState');
460
449
  });
461
450
  it('should throw when sharing is not found', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee8() {
462
451
  return _regeneratorRuntime.wrap(function _callee8$(_context8) {
@@ -484,36 +473,33 @@ describe('updateSharingMemberType', function () {
484
473
  }
485
474
  }, _callee9);
486
475
  })));
487
- it('should throw when contact is not found', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee10() {
476
+ it('should call setReadOnly when switching to one-way', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee10() {
488
477
  return _regeneratorRuntime.wrap(function _callee10$(_context10) {
489
478
  while (1) switch (_context10.prev = _context10.next) {
490
479
  case 0:
491
- mockContactsCollection.find.mockResolvedValue({
492
- data: []
493
- });
494
- _context10.next = 3;
495
- return expect(instance.updateSharingMemberType('sharing-123', 1, 'one-way')).rejects.toThrow('Contact not found');
480
+ _context10.next = 2;
481
+ return instance.updateSharingMemberType('sharing-123', 1, 'one-way');
496
482
 
497
- case 3:
483
+ case 2:
484
+ expect(instance.sharingCol.setReadOnly).toHaveBeenCalledWith(mockSharing, 1);
485
+ expect(instance.sharingCol.setReadWrite).not.toHaveBeenCalled();
486
+
487
+ case 4:
498
488
  case "end":
499
489
  return _context10.stop();
500
490
  }
501
491
  }, _callee10);
502
492
  })));
503
- it('should revoke and re-add with readOnly when switching to one-way', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee11() {
493
+ it('should call setReadWrite when switching to two-way', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee11() {
504
494
  return _regeneratorRuntime.wrap(function _callee11$(_context11) {
505
495
  while (1) switch (_context11.prev = _context11.next) {
506
496
  case 0:
507
497
  _context11.next = 2;
508
- return instance.updateSharingMemberType('sharing-123', 1, 'one-way');
498
+ return instance.updateSharingMemberType('sharing-123', 1, 'two-way');
509
499
 
510
500
  case 2:
511
- expect(instance.sharingCol.revokeRecipient).toHaveBeenCalledWith(mockSharing, 1);
512
- expect(instance.addRecipients).toHaveBeenCalledWith({
513
- document: mockSharing,
514
- recipients: [],
515
- readOnlyRecipients: [mockContact]
516
- });
501
+ expect(instance.sharingCol.setReadWrite).toHaveBeenCalledWith(mockSharing, 1);
502
+ expect(instance.sharingCol.setReadOnly).not.toHaveBeenCalled();
517
503
 
518
504
  case 4:
519
505
  case "end":
@@ -521,34 +507,31 @@ describe('updateSharingMemberType', function () {
521
507
  }
522
508
  }, _callee11);
523
509
  })));
524
- it('should revoke and re-add as read-write when switching to two-way', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee12() {
510
+ it('should not dispatch a state update realtime refreshes the store', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee12() {
525
511
  return _regeneratorRuntime.wrap(function _callee12$(_context12) {
526
512
  while (1) switch (_context12.prev = _context12.next) {
527
513
  case 0:
528
514
  _context12.next = 2;
529
- return instance.updateSharingMemberType('sharing-123', 1, 'two-way');
515
+ return instance.updateSharingMemberType('sharing-123', 1, 'one-way');
530
516
 
531
517
  case 2:
532
- expect(instance.sharingCol.revokeRecipient).toHaveBeenCalledWith(mockSharing, 1);
533
- expect(instance.addRecipients).toHaveBeenCalledWith({
534
- document: mockSharing,
535
- recipients: [mockContact],
536
- readOnlyRecipients: []
537
- });
518
+ expect(instance.setState).not.toHaveBeenCalled();
538
519
 
539
- case 4:
520
+ case 3:
540
521
  case "end":
541
522
  return _context12.stop();
542
523
  }
543
524
  }, _callee12);
544
525
  })));
545
- it('should rethrow error if addRecipients fails after revocation', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee13() {
526
+ it('should not dispatch a state update for two-way — realtime refreshes the store', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee13() {
546
527
  return _regeneratorRuntime.wrap(function _callee13$(_context13) {
547
528
  while (1) switch (_context13.prev = _context13.next) {
548
529
  case 0:
549
- instance.addRecipients.mockRejectedValue(new Error('Network error'));
550
- _context13.next = 3;
551
- return expect(instance.updateSharingMemberType('sharing-123', 1, 'one-way')).rejects.toThrow('Network error');
530
+ _context13.next = 2;
531
+ return instance.updateSharingMemberType('sharing-123', 1, 'two-way');
532
+
533
+ case 2:
534
+ expect(instance.setState).not.toHaveBeenCalled();
552
535
 
553
536
  case 3:
554
537
  case "end":
@@ -556,6 +539,51 @@ describe('updateSharingMemberType', function () {
556
539
  }
557
540
  }, _callee13);
558
541
  })));
542
+ it('should rethrow error if setReadOnly fails', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee14() {
543
+ return _regeneratorRuntime.wrap(function _callee14$(_context14) {
544
+ while (1) switch (_context14.prev = _context14.next) {
545
+ case 0:
546
+ instance.sharingCol.setReadOnly.mockRejectedValue(new Error('Network error'));
547
+ _context14.next = 3;
548
+ return expect(instance.updateSharingMemberType('sharing-123', 1, 'one-way')).rejects.toThrow('Network error');
549
+
550
+ case 3:
551
+ case "end":
552
+ return _context14.stop();
553
+ }
554
+ }, _callee14);
555
+ })));
556
+ it('should rethrow error if setReadWrite fails', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee15() {
557
+ return _regeneratorRuntime.wrap(function _callee15$(_context15) {
558
+ while (1) switch (_context15.prev = _context15.next) {
559
+ case 0:
560
+ instance.sharingCol.setReadWrite.mockRejectedValue(new Error('Network error'));
561
+ _context15.next = 3;
562
+ return expect(instance.updateSharingMemberType('sharing-123', 1, 'two-way')).rejects.toThrow('Network error');
563
+
564
+ case 3:
565
+ case "end":
566
+ return _context15.stop();
567
+ }
568
+ }, _callee15);
569
+ })));
570
+ it('should not dispatch state update if API call fails', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee16() {
571
+ return _regeneratorRuntime.wrap(function _callee16$(_context16) {
572
+ while (1) switch (_context16.prev = _context16.next) {
573
+ case 0:
574
+ instance.sharingCol.setReadOnly.mockRejectedValue(new Error('Network error'));
575
+ _context16.next = 3;
576
+ return expect(instance.updateSharingMemberType('sharing-123', 1, 'one-way')).rejects.toThrow('Network error');
577
+
578
+ case 3:
579
+ expect(instance.setState).not.toHaveBeenCalled();
580
+
581
+ case 4:
582
+ case "end":
583
+ return _context16.stop();
584
+ }
585
+ }, _callee16);
586
+ })));
559
587
  }); // TODO Convert with react-testing-library
560
588
  // describe('hasWriteAccess', () => {
561
589
  // it('tells if a doc is writtable', () => {
@@ -1,5 +1,6 @@
1
1
  import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator";
2
2
  import _regeneratorRuntime from "@babel/runtime/regenerator";
3
+ import { endOfDay } from 'date-fns';
3
4
  import { generateWebLink } from 'cozy-client';
4
5
  import minilog from 'cozy-minilog';
5
6
  import { getAppSlugFromDocumentType } from '../../helpers/link';
@@ -7,6 +8,21 @@ import { getShortcode } from '../../helpers/shortcodes';
7
8
  var log = minilog('ShareRestrictionModal/helpers');
8
9
  export var WRITE_PERMS = ['GET', 'POST', 'PUT', 'PATCH'];
9
10
  export var READ_ONLY_PERMS = ['GET'];
11
+ /**
12
+ * A share link expires at the END of the selected day, so picking today keeps
13
+ * the link reachable for the rest of today and expires it the next day.
14
+ * Without this, the picked day defaults to its midnight (start of day), which
15
+ * is already in the past and makes the link unreachable as soon as it is set.
16
+ * @param {Date|string|null|undefined} date - The picked expiration day
17
+ * @returns {Date|null|undefined} - The same value normalized to end of day
18
+ */
19
+
20
+ export var toExpirationDate = function toExpirationDate(date) {
21
+ if (!date) return date;
22
+ var parsed = date instanceof Date ? date : new Date(date);
23
+ if (parsed.toString() === 'Invalid Date') return date;
24
+ return endOfDay(parsed);
25
+ };
10
26
  /**
11
27
  * Create a sharing link for a file with specified options
12
28
  * @param {object} options
@@ -250,7 +266,8 @@ export var makeTTL = function makeTTL(selectedDate) {
250
266
 
251
267
  if (selectedDateConverted instanceof Date) {
252
268
  var now = new Date();
253
- var ttl = selectedDateConverted > now ? "".concat(Math.round((selectedDateConverted - now) / 1000), "s") : undefined;
269
+ var expiration = toExpirationDate(selectedDateConverted);
270
+ var ttl = expiration > now ? "".concat(Math.round((expiration - now) / 1000), "s") : undefined;
254
271
  return ttl;
255
272
  }
256
273
 
@@ -326,13 +343,14 @@ export var createPermissions = /*#__PURE__*/function () {
326
343
 
327
344
  export var updatePermissions = /*#__PURE__*/function () {
328
345
  var _ref9 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee4(_ref8) {
329
- var file, t, dateToggle, selectedDate, passwordToggle, password, editingRights, documentType, updateDocumentPermissions, showAlert, expiresAt, ensurePassword, verbs;
346
+ var file, t, dateToggle, selectedDate, passwordToggle, password, editingRights, documentType, updateDocumentPermissions, showAlert, _toExpirationDate, expiresAt, ensurePassword, verbs;
347
+
330
348
  return _regeneratorRuntime.wrap(function _callee4$(_context4) {
331
349
  while (1) switch (_context4.prev = _context4.next) {
332
350
  case 0:
333
351
  file = _ref8.file, t = _ref8.t, dateToggle = _ref8.dateToggle, selectedDate = _ref8.selectedDate, passwordToggle = _ref8.passwordToggle, password = _ref8.password, editingRights = _ref8.editingRights, documentType = _ref8.documentType, updateDocumentPermissions = _ref8.updateDocumentPermissions, showAlert = _ref8.showAlert;
334
352
  _context4.prev = 1;
335
- expiresAt = dateToggle ? (selectedDate === null || selectedDate === void 0 ? void 0 : selectedDate.toISOString()) || undefined : '';
353
+ expiresAt = dateToggle ? ((_toExpirationDate = toExpirationDate(selectedDate)) === null || _toExpirationDate === void 0 ? void 0 : _toExpirationDate.toISOString()) || undefined : '';
336
354
  ensurePassword = passwordToggle ? password || undefined : '';
337
355
  verbs = editingRights === 'readOnly' ? READ_ONLY_PERMS : WRITE_PERMS;
338
356
  return _context4.abrupt("return", updateDocumentPermissions(file, {
@@ -1,7 +1,23 @@
1
1
  import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator";
2
2
  import _regeneratorRuntime from "@babel/runtime/regenerator";
3
- import { makeTTL, revokePermissions, updatePermissions } from './helpers';
3
+ import { endOfDay } from 'date-fns';
4
+ import { makeTTL, revokePermissions, toExpirationDate, updatePermissions } from './helpers';
4
5
  describe('ShareRestrictionModal/helpers', function () {
6
+ describe('toExpirationDate', function () {
7
+ it('returns falsy values unchanged', function () {
8
+ expect(toExpirationDate(null)).toBeNull();
9
+ expect(toExpirationDate(undefined)).toBeUndefined();
10
+ expect(toExpirationDate('')).toBe('');
11
+ });
12
+ it('normalizes a Date to the end of its day', function () {
13
+ var date = new Date(2100, 0, 1, 9, 30);
14
+ expect(toExpirationDate(date)).toEqual(endOfDay(date));
15
+ });
16
+ it('accepts an ISO string', function () {
17
+ var iso = '2100-01-01T09:30:00.000Z';
18
+ expect(toExpirationDate(iso)).toEqual(endOfDay(new Date(iso)));
19
+ });
20
+ });
5
21
  describe('makeTTL', function () {
6
22
  it('sould return undefined', function () {
7
23
  expect(makeTTL()).toBeUndefined();
@@ -15,6 +31,18 @@ describe('ShareRestrictionModal/helpers', function () {
15
31
  expect(makeTTL(new Date('2100-01-01T00:00:00.000Z'))).toBeDefined();
16
32
  expect(makeTTL('2100-01-01T00:00:00.000Z')).toBeDefined();
17
33
  });
34
+ it('keeps a link picked for today valid until the end of the day', function () {
35
+ // Regression: picking "today" used to resolve to midnight (already past),
36
+ // which dropped the TTL and the link never expired or was unreachable.
37
+ jest.useFakeTimers();
38
+ jest.setSystemTime(new Date('2100-01-01T12:00:00.000Z'));
39
+
40
+ try {
41
+ expect(makeTTL(new Date())).toBeDefined();
42
+ } finally {
43
+ jest.useRealTimers();
44
+ }
45
+ });
18
46
  });
19
47
  describe('updatePermissions', function () {
20
48
  var documentType = 'Files';
@@ -163,7 +191,7 @@ describe('ShareRestrictionModal/helpers', function () {
163
191
  }, _callee4);
164
192
  })));
165
193
  it('should call updateDocumentPermissions with expected date & password if their switches are true', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee5() {
166
- var updateDocumentPermissions, file;
194
+ var updateDocumentPermissions, file, selectedDate;
167
195
  return _regeneratorRuntime.wrap(function _callee5$(_context5) {
168
196
  while (1) switch (_context5.prev = _context5.next) {
169
197
  case 0:
@@ -171,12 +199,13 @@ describe('ShareRestrictionModal/helpers', function () {
171
199
  file = {
172
200
  _id: '123'
173
201
  };
174
- _context5.next = 4;
202
+ selectedDate = new Date('2100-01-01T00:00:00.000Z');
203
+ _context5.next = 5;
175
204
  return updatePermissions({
176
205
  file: file,
177
206
  t: jest.fn(),
178
207
  dateToggle: true,
179
- selectedDate: new Date('2100-01-01T00:00:00.000Z'),
208
+ selectedDate: selectedDate,
180
209
  passwordToggle: true,
181
210
  password: '1234',
182
211
  editingRights: 'readOnly',
@@ -185,32 +214,77 @@ describe('ShareRestrictionModal/helpers', function () {
185
214
  showAlert: jest.fn()
186
215
  });
187
216
 
188
- case 4:
217
+ case 5:
189
218
  expect(updateDocumentPermissions).toHaveBeenCalledWith(file, {
190
- expiresAt: '2100-01-01T00:00:00.000Z',
219
+ expiresAt: endOfDay(selectedDate).toISOString(),
191
220
  password: '1234',
192
221
  verbs: ['GET']
193
222
  });
194
223
 
195
- case 5:
224
+ case 6:
196
225
  case "end":
197
226
  return _context5.stop();
198
227
  }
199
228
  }, _callee5);
200
229
  })));
230
+ it('sets expiration to the end of the selected day so picking today stays valid', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee6() {
231
+ var updateDocumentPermissions, file, today, expiresAt;
232
+ return _regeneratorRuntime.wrap(function _callee6$(_context6) {
233
+ while (1) switch (_context6.prev = _context6.next) {
234
+ case 0:
235
+ // Freeze time so the "expires in the future" assertion can't flake near
236
+ // midnight or under slow CI.
237
+ jest.useFakeTimers();
238
+ jest.setSystemTime(new Date('2100-01-01T12:00:00.000Z'));
239
+ _context6.prev = 2;
240
+ updateDocumentPermissions = jest.fn();
241
+ file = {
242
+ _id: '123'
243
+ };
244
+ today = new Date();
245
+ _context6.next = 8;
246
+ return updatePermissions({
247
+ file: file,
248
+ t: jest.fn(),
249
+ dateToggle: true,
250
+ selectedDate: today,
251
+ passwordToggle: false,
252
+ password: '',
253
+ editingRights: 'readOnly',
254
+ documentType: documentType,
255
+ updateDocumentPermissions: updateDocumentPermissions,
256
+ showAlert: jest.fn()
257
+ });
258
+
259
+ case 8:
260
+ expiresAt = updateDocumentPermissions.mock.calls[0][1].expiresAt;
261
+ expect(expiresAt).toBe(endOfDay(today).toISOString());
262
+ expect(new Date(expiresAt).getTime()).toBeGreaterThan(Date.now());
263
+
264
+ case 11:
265
+ _context6.prev = 11;
266
+ jest.useRealTimers();
267
+ return _context6.finish(11);
268
+
269
+ case 14:
270
+ case "end":
271
+ return _context6.stop();
272
+ }
273
+ }, _callee6, null, [[2,, 11, 14]]);
274
+ })));
201
275
  });
202
276
  describe('revokePermissions', function () {
203
277
  var documentType = 'Files';
204
- it('should call revokeSharingLink', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee6() {
278
+ it('should call revokeSharingLink', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee7() {
205
279
  var revokeSharingLink, file;
206
- return _regeneratorRuntime.wrap(function _callee6$(_context6) {
207
- while (1) switch (_context6.prev = _context6.next) {
280
+ return _regeneratorRuntime.wrap(function _callee7$(_context7) {
281
+ while (1) switch (_context7.prev = _context7.next) {
208
282
  case 0:
209
283
  revokeSharingLink = jest.fn();
210
284
  file = {
211
285
  _id: '123'
212
286
  };
213
- _context6.next = 4;
287
+ _context7.next = 4;
214
288
  return revokePermissions({
215
289
  file: file,
216
290
  t: jest.fn(),
@@ -224,9 +298,9 @@ describe('ShareRestrictionModal/helpers', function () {
224
298
 
225
299
  case 5:
226
300
  case "end":
227
- return _context6.stop();
301
+ return _context7.stop();
228
302
  }
229
- }, _callee6);
303
+ }, _callee7);
230
304
  })));
231
305
  });
232
306
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-sharing",
3
- "version": "33.3.0",
3
+ "version": "33.3.2",
4
4
  "description": "Provides sharing login for React applications.",
5
5
  "main": "dist/index.js",
6
6
  "author": "Cozy",
@@ -53,7 +53,7 @@
53
53
  "babel-jest": "30.3.0",
54
54
  "babel-plugin-css-modules-transform": "1.6.2",
55
55
  "babel-plugin-inline-react-svg": "1.1.2",
56
- "cozy-client": "^60.24.0",
56
+ "cozy-client": "^60.27.0",
57
57
  "cozy-device-helper": "^4.0.0",
58
58
  "cozy-intent": "^2.31.1",
59
59
  "cozy-minilog": "^3.10.1",
@@ -68,7 +68,7 @@
68
68
  "twake-i18n": "^0.4.1"
69
69
  },
70
70
  "peerDependencies": {
71
- "cozy-client": ">=60.24.0",
71
+ "cozy-client": ">=60.27.0",
72
72
  "cozy-device-helper": ">=4.0.0",
73
73
  "cozy-flags": ">=4.8.1",
74
74
  "cozy-intent": ">=2.29.1",
@@ -83,5 +83,5 @@
83
83
  "sideEffects": [
84
84
  "*.css"
85
85
  ],
86
- "gitHead": "4fe0199d7042ff150315bd4a097256b2364f200c"
86
+ "gitHead": "08272a4e1b47e169b6f4af662e30a40e4e9a4124"
87
87
  }
@@ -143,7 +143,7 @@ export class SharingProvider extends Component {
143
143
  this.fetchAllSharings()
144
144
  if (!client.plugins.realtime) {
145
145
  // eslint-disable-next-line
146
- console.warn(
146
+ console.warn(
147
147
  `You should register the realtime plugin to your CozyClient instance see https://docs.cozy.io/en/cozy-realtime/#example`
148
148
  )
149
149
  } else {
@@ -353,36 +353,19 @@ export class SharingProvider extends Component {
353
353
  const member = sharing.attributes.members[memberIndex]
354
354
  if (!member) throw new Error('Member not found')
355
355
 
356
- // Find the contact by email (same pattern as helpers/contacts.js)
357
- const matchedContacts = await this.props.client
358
- .collection('io.cozy.contacts')
359
- .find(
360
- {
361
- email: { $elemMatch: { address: member.email } },
362
- _id: { $gt: null }
363
- },
364
- { indexedFields: ['_id'] }
365
- )
366
-
367
- if (!matchedContacts.data.length) {
368
- throw new Error(`Contact not found for member ${member.email}`)
356
+ const makeReadOnly = newType === 'one-way'
357
+ if (newType !== 'one-way' && newType !== 'two-way') {
358
+ throw new Error('Unsupported sharing type: ' + newType)
369
359
  }
370
- const contact = matchedContacts.data[0]
371
-
372
- // Revoke (API call only, no state dispatch to avoid UI flickering)
373
- await this.sharingCol.revokeRecipient(sharing, memberIndex)
374
-
375
- // Re-add with the new permission type
376
- const readOnly = newType === 'one-way'
377
360
  try {
378
- await this.addRecipients({
379
- document: sharing,
380
- recipients: readOnly ? [] : [contact],
381
- readOnlyRecipients: readOnly ? [contact] : []
382
- })
361
+ if (makeReadOnly) {
362
+ await this.sharingCol.setReadOnly(sharing, memberIndex)
363
+ } else {
364
+ await this.sharingCol.setReadWrite(sharing, memberIndex)
365
+ }
383
366
  } catch (error) {
384
367
  log.error(
385
- `Failed to re-add member ${member.email} after revocation. The member has been revoked but could not be re-added with the new permission type.`,
368
+ `Failed to change member ${member.email} permission type to ${newType}`,
386
369
  error
387
370
  )
388
371
  throw error
@@ -292,7 +292,6 @@ describe('updateDocumentPermissions', () => {
292
292
  })
293
293
 
294
294
  describe('updateSharingMemberType', () => {
295
- const mockContact = { _id: 'contact-1', email: [{ address: 'bob@bob.cozy' }] }
296
295
  const mockSharing = {
297
296
  id: 'sharing-123',
298
297
  type: 'io.cozy.sharings',
@@ -305,16 +304,11 @@ describe('updateSharingMemberType', () => {
305
304
  }
306
305
 
307
306
  let instance
308
- let mockContactsCollection
309
307
 
310
308
  beforeEach(() => {
311
- mockContactsCollection = {
312
- find: jest.fn().mockResolvedValue({ data: [mockContact] })
313
- }
314
-
315
309
  const mockClient = {
316
310
  getStackClient: () => ({ uri: 'http://cozy.local' }),
317
- collection: jest.fn().mockReturnValue(mockContactsCollection)
311
+ collection: jest.fn().mockReturnValue({})
318
312
  }
319
313
 
320
314
  instance = new SharingProvider({ client: mockClient })
@@ -323,9 +317,10 @@ describe('updateSharingMemberType', () => {
323
317
  sharings: [mockSharing]
324
318
  }
325
319
  instance.sharingCol = {
326
- revokeRecipient: jest.fn().mockResolvedValue({})
320
+ setReadOnly: jest.fn().mockResolvedValue({}),
321
+ setReadWrite: jest.fn().mockResolvedValue({})
327
322
  }
328
- jest.spyOn(instance, 'addRecipients').mockResolvedValue({})
323
+ jest.spyOn(instance, 'setState')
329
324
  })
330
325
 
331
326
  it('should throw when sharing is not found', async () => {
@@ -340,48 +335,65 @@ describe('updateSharingMemberType', () => {
340
335
  ).rejects.toThrow('Member not found')
341
336
  })
342
337
 
343
- it('should throw when contact is not found', async () => {
344
- mockContactsCollection.find.mockResolvedValue({ data: [] })
338
+ it('should call setReadOnly when switching to one-way', async () => {
339
+ await instance.updateSharingMemberType('sharing-123', 1, 'one-way')
345
340
 
346
- await expect(
347
- instance.updateSharingMemberType('sharing-123', 1, 'one-way')
348
- ).rejects.toThrow('Contact not found')
341
+ expect(instance.sharingCol.setReadOnly).toHaveBeenCalledWith(mockSharing, 1)
342
+ expect(instance.sharingCol.setReadWrite).not.toHaveBeenCalled()
349
343
  })
350
344
 
351
- it('should revoke and re-add with readOnly when switching to one-way', async () => {
352
- await instance.updateSharingMemberType('sharing-123', 1, 'one-way')
345
+ it('should call setReadWrite when switching to two-way', async () => {
346
+ await instance.updateSharingMemberType('sharing-123', 1, 'two-way')
353
347
 
354
- expect(instance.sharingCol.revokeRecipient).toHaveBeenCalledWith(
348
+ expect(instance.sharingCol.setReadWrite).toHaveBeenCalledWith(
355
349
  mockSharing,
356
350
  1
357
351
  )
358
- expect(instance.addRecipients).toHaveBeenCalledWith({
359
- document: mockSharing,
360
- recipients: [],
361
- readOnlyRecipients: [mockContact]
362
- })
352
+ expect(instance.sharingCol.setReadOnly).not.toHaveBeenCalled()
363
353
  })
364
354
 
365
- it('should revoke and re-add as read-write when switching to two-way', async () => {
355
+ it('should not dispatch a state update realtime refreshes the store', async () => {
356
+ await instance.updateSharingMemberType('sharing-123', 1, 'one-way')
357
+
358
+ expect(instance.setState).not.toHaveBeenCalled()
359
+ })
360
+
361
+ it('should not dispatch a state update for two-way — realtime refreshes the store', async () => {
366
362
  await instance.updateSharingMemberType('sharing-123', 1, 'two-way')
367
363
 
368
- expect(instance.sharingCol.revokeRecipient).toHaveBeenCalledWith(
369
- mockSharing,
370
- 1
364
+ expect(instance.setState).not.toHaveBeenCalled()
365
+ })
366
+
367
+ it('should rethrow error if setReadOnly fails', async () => {
368
+ instance.sharingCol.setReadOnly.mockRejectedValue(
369
+ new Error('Network error')
371
370
  )
372
- expect(instance.addRecipients).toHaveBeenCalledWith({
373
- document: mockSharing,
374
- recipients: [mockContact],
375
- readOnlyRecipients: []
376
- })
371
+
372
+ await expect(
373
+ instance.updateSharingMemberType('sharing-123', 1, 'one-way')
374
+ ).rejects.toThrow('Network error')
375
+ })
376
+
377
+ it('should rethrow error if setReadWrite fails', async () => {
378
+ instance.sharingCol.setReadWrite.mockRejectedValue(
379
+ new Error('Network error')
380
+ )
381
+
382
+ await expect(
383
+ instance.updateSharingMemberType('sharing-123', 1, 'two-way')
384
+ ).rejects.toThrow('Network error')
377
385
  })
378
386
 
379
- it('should rethrow error if addRecipients fails after revocation', async () => {
380
- instance.addRecipients.mockRejectedValue(new Error('Network error'))
387
+ it('should not dispatch state update if API call fails', async () => {
388
+ instance.sharingCol.setReadOnly.mockRejectedValue(
389
+ new Error('Network error')
390
+ )
381
391
 
382
392
  await expect(
383
393
  instance.updateSharingMemberType('sharing-123', 1, 'one-way')
384
394
  ).rejects.toThrow('Network error')
395
+
396
+ expect(instance.setState).not.toHaveBeenCalled()
385
397
  })
386
398
  })
387
399
 
@@ -1,3 +1,5 @@
1
+ import { endOfDay } from 'date-fns'
2
+
1
3
  import { generateWebLink } from 'cozy-client'
2
4
  import minilog from 'cozy-minilog'
3
5
 
@@ -9,6 +11,21 @@ const log = minilog('ShareRestrictionModal/helpers')
9
11
  export const WRITE_PERMS = ['GET', 'POST', 'PUT', 'PATCH']
10
12
  export const READ_ONLY_PERMS = ['GET']
11
13
 
14
+ /**
15
+ * A share link expires at the END of the selected day, so picking today keeps
16
+ * the link reachable for the rest of today and expires it the next day.
17
+ * Without this, the picked day defaults to its midnight (start of day), which
18
+ * is already in the past and makes the link unreachable as soon as it is set.
19
+ * @param {Date|string|null|undefined} date - The picked expiration day
20
+ * @returns {Date|null|undefined} - The same value normalized to end of day
21
+ */
22
+ export const toExpirationDate = date => {
23
+ if (!date) return date
24
+ const parsed = date instanceof Date ? date : new Date(date)
25
+ if (parsed.toString() === 'Invalid Date') return date
26
+ return endOfDay(parsed)
27
+ }
28
+
12
29
  /**
13
30
  * Create a sharing link for a file with specified options
14
31
  * @param {object} options
@@ -161,9 +178,10 @@ export const makeTTL = selectedDate => {
161
178
  }
162
179
  if (selectedDateConverted instanceof Date) {
163
180
  const now = new Date()
181
+ const expiration = toExpirationDate(selectedDateConverted)
164
182
  const ttl =
165
- selectedDateConverted > now
166
- ? `${Math.round((selectedDateConverted - now) / 1000)}s`
183
+ expiration > now
184
+ ? `${Math.round((expiration - now) / 1000)}s`
167
185
  : undefined
168
186
  return ttl
169
187
  }
@@ -239,7 +257,9 @@ export const updatePermissions = async ({
239
257
  showAlert
240
258
  }) => {
241
259
  try {
242
- const expiresAt = dateToggle ? selectedDate?.toISOString() || undefined : ''
260
+ const expiresAt = dateToggle
261
+ ? toExpirationDate(selectedDate)?.toISOString() || undefined
262
+ : ''
243
263
  const ensurePassword = passwordToggle ? password || undefined : ''
244
264
  const verbs = editingRights === 'readOnly' ? READ_ONLY_PERMS : WRITE_PERMS
245
265
  return updateDocumentPermissions(file, {
@@ -1,6 +1,29 @@
1
- import { makeTTL, revokePermissions, updatePermissions } from './helpers'
1
+ import { endOfDay } from 'date-fns'
2
+
3
+ import {
4
+ makeTTL,
5
+ revokePermissions,
6
+ toExpirationDate,
7
+ updatePermissions
8
+ } from './helpers'
2
9
 
3
10
  describe('ShareRestrictionModal/helpers', () => {
11
+ describe('toExpirationDate', () => {
12
+ it('returns falsy values unchanged', () => {
13
+ expect(toExpirationDate(null)).toBeNull()
14
+ expect(toExpirationDate(undefined)).toBeUndefined()
15
+ expect(toExpirationDate('')).toBe('')
16
+ })
17
+ it('normalizes a Date to the end of its day', () => {
18
+ const date = new Date(2100, 0, 1, 9, 30)
19
+ expect(toExpirationDate(date)).toEqual(endOfDay(date))
20
+ })
21
+ it('accepts an ISO string', () => {
22
+ const iso = '2100-01-01T09:30:00.000Z'
23
+ expect(toExpirationDate(iso)).toEqual(endOfDay(new Date(iso)))
24
+ })
25
+ })
26
+
4
27
  describe('makeTTL', () => {
5
28
  it('sould return undefined', () => {
6
29
  expect(makeTTL()).toBeUndefined()
@@ -14,6 +37,17 @@ describe('ShareRestrictionModal/helpers', () => {
14
37
  expect(makeTTL(new Date('2100-01-01T00:00:00.000Z'))).toBeDefined()
15
38
  expect(makeTTL('2100-01-01T00:00:00.000Z')).toBeDefined()
16
39
  })
40
+ it('keeps a link picked for today valid until the end of the day', () => {
41
+ // Regression: picking "today" used to resolve to midnight (already past),
42
+ // which dropped the TTL and the link never expired or was unreachable.
43
+ jest.useFakeTimers()
44
+ jest.setSystemTime(new Date('2100-01-01T12:00:00.000Z'))
45
+ try {
46
+ expect(makeTTL(new Date())).toBeDefined()
47
+ } finally {
48
+ jest.useRealTimers()
49
+ }
50
+ })
17
51
  })
18
52
 
19
53
  describe('updatePermissions', () => {
@@ -118,11 +152,12 @@ describe('ShareRestrictionModal/helpers', () => {
118
152
  const updateDocumentPermissions = jest.fn()
119
153
  const file = { _id: '123' }
120
154
 
155
+ const selectedDate = new Date('2100-01-01T00:00:00.000Z')
121
156
  await updatePermissions({
122
157
  file,
123
158
  t: jest.fn(),
124
159
  dateToggle: true,
125
- selectedDate: new Date('2100-01-01T00:00:00.000Z'),
160
+ selectedDate,
126
161
  passwordToggle: true,
127
162
  password: '1234',
128
163
  editingRights: 'readOnly',
@@ -132,11 +167,42 @@ describe('ShareRestrictionModal/helpers', () => {
132
167
  })
133
168
 
134
169
  expect(updateDocumentPermissions).toHaveBeenCalledWith(file, {
135
- expiresAt: '2100-01-01T00:00:00.000Z',
170
+ expiresAt: endOfDay(selectedDate).toISOString(),
136
171
  password: '1234',
137
172
  verbs: ['GET']
138
173
  })
139
174
  })
175
+
176
+ it('sets expiration to the end of the selected day so picking today stays valid', async () => {
177
+ // Freeze time so the "expires in the future" assertion can't flake near
178
+ // midnight or under slow CI.
179
+ jest.useFakeTimers()
180
+ jest.setSystemTime(new Date('2100-01-01T12:00:00.000Z'))
181
+ try {
182
+ const updateDocumentPermissions = jest.fn()
183
+ const file = { _id: '123' }
184
+ const today = new Date()
185
+
186
+ await updatePermissions({
187
+ file,
188
+ t: jest.fn(),
189
+ dateToggle: true,
190
+ selectedDate: today,
191
+ passwordToggle: false,
192
+ password: '',
193
+ editingRights: 'readOnly',
194
+ documentType,
195
+ updateDocumentPermissions,
196
+ showAlert: jest.fn()
197
+ })
198
+
199
+ const { expiresAt } = updateDocumentPermissions.mock.calls[0][1]
200
+ expect(expiresAt).toBe(endOfDay(today).toISOString())
201
+ expect(new Date(expiresAt).getTime()).toBeGreaterThan(Date.now())
202
+ } finally {
203
+ jest.useRealTimers()
204
+ }
205
+ })
140
206
  })
141
207
 
142
208
  describe('revokePermissions', () => {