cozy-sharing 37.6.1 → 37.7.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
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
+ # [37.7.0](https://github.com/cozy/cozy-libs/compare/cozy-sharing@37.6.2...cozy-sharing@37.7.0) (2026-09-15)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **cozy-sharing:** Stop getSharedParentPath self-matching ([54c6bfe](https://github.com/cozy/cozy-libs/commit/54c6bfe7874ebdfd8174905e749e9a0b584457a1))
11
+
12
+ ### Features
13
+
14
+ - **cozy-sharing:** Confirm downgrade affecting a shared parent folder ([7a57bd9](https://github.com/cozy/cozy-libs/commit/7a57bd9b34ae93a0a394e69fc3f272a3ca4969d6))
15
+
16
+ ## [37.6.2](https://github.com/cozy/cozy-libs/compare/cozy-sharing@37.6.1...cozy-sharing@37.6.2) (2026-09-14)
17
+
18
+ **Note:** Version bump only for package cozy-sharing
19
+
6
20
  ## [37.6.1](https://github.com/cozy/cozy-libs/compare/cozy-sharing@37.6.0...cozy-sharing@37.6.1) (2026-09-10)
7
21
 
8
22
  **Note:** Version bump only for package cozy-sharing
package/README.md CHANGED
@@ -178,6 +178,7 @@ Link and email sharing can coexist on the same resource. The recipient's permiss
178
178
  | Read-only sharing | If the existing sharing is read-only, only the `readOnly` option is offered | `ShareByEmail.jsx:46-57` |
179
179
  | Contacts shown | Only contacts with a defined `email` or `cozy` URL | `helpers/recipients.js:72-73` |
180
180
  | Group sharing | All members get the same permission; dynamic membership (add/remove) propagates | `state.js:414-451` |
181
+ | Downgrade confirmation | Switching a member from `two-way` (editor) to `one-way` (viewer) on a folder with a shared parent first opens `DowngradePermissionConfirmDialog`: the change is applied only on confirm, cancel keeps the editor permission (the downgrade also reduces the member's rights on the parent folder) | `Recipient/PermissionTypeMenu.jsx`, `Recipient/DowngradePermissionConfirmDialog.jsx` |
181
182
 
182
183
  ### Shared drives & federated folders
183
184
 
@@ -1,5 +1,5 @@
1
- import PropTypes from 'prop-types';
2
1
  import React from 'react';
2
+ import { useI18n } from 'twake-i18n';
3
3
  import Identity from './Identity';
4
4
  import { getDisplayName } from '../../models';
5
5
  var styles = {
@@ -17,9 +17,12 @@ var styles = {
17
17
  };
18
18
  import { ExtraAvatar } from '../Avatar/ExtraAvatar';
19
19
 
20
- var ExtraIdentity = function ExtraIdentity(_ref, _ref2) {
20
+ var ExtraIdentity = function ExtraIdentity(_ref) {
21
21
  var extraRecipients = _ref.extraRecipients;
22
- var t = _ref2.t;
22
+
23
+ var _useI18n = useI18n(),
24
+ t = _useI18n.t;
25
+
23
26
  return /*#__PURE__*/React.createElement("div", {
24
27
  className: styles['recipient']
25
28
  }, /*#__PURE__*/React.createElement(ExtraAvatar, {
@@ -33,7 +36,4 @@ var ExtraIdentity = function ExtraIdentity(_ref, _ref2) {
33
36
  })));
34
37
  };
35
38
 
36
- ExtraIdentity.contextTypes = {
37
- t: PropTypes.func.isRequired
38
- };
39
39
  export { ExtraIdentity };
@@ -0,0 +1,127 @@
1
+ import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
2
+ import PropTypes from 'prop-types';
3
+ import React from 'react';
4
+ import snarkdown from 'snarkdown';
5
+ import { Icon, FolderOutlined, ArrowUp } from '@linagora/twake-icons';
6
+ import { useClient } from 'cozy-client';
7
+ import Button from 'cozy-ui/transpiled/react/Buttons';
8
+ import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs';
9
+ import Timeline from 'cozy-ui/transpiled/react/Timeline';
10
+ import TimelineConnector from 'cozy-ui/transpiled/react/TimelineConnector';
11
+ import TimelineContent from 'cozy-ui/transpiled/react/TimelineContent';
12
+ import TimelineDot from 'cozy-ui/transpiled/react/TimelineDot';
13
+ import TimelineItem from 'cozy-ui/transpiled/react/TimelineItem';
14
+ import TimelineOppositeContent from 'cozy-ui/transpiled/react/TimelineOppositeContent';
15
+ import TimelineSeparator from 'cozy-ui/transpiled/react/TimelineSeparator';
16
+ import Typography from 'cozy-ui/transpiled/react/Typography';
17
+ import { useI18n } from 'twake-i18n';
18
+ import { DEFAULT_DISPLAY_NAME } from '../../helpers/recipients';
19
+ import { useFetchDocumentPath } from '../../hooks/useFetchDocumentPath';
20
+ import { useSharingContext } from '../../hooks/useSharingContext';
21
+ import { getDisplayName } from '../../models';
22
+
23
+ var DowngradePermissionConfirmDialog = function DowngradePermissionConfirmDialog(_ref) {
24
+ var document = _ref.document,
25
+ recipient = _ref.recipient,
26
+ onCancel = _ref.onCancel,
27
+ onConfirm = _ref.onConfirm;
28
+
29
+ var _useI18n = useI18n(),
30
+ t = _useI18n.t;
31
+
32
+ var client = useClient();
33
+
34
+ var _useSharingContext = useSharingContext(),
35
+ getSharedParentPath = _useSharingContext.getSharedParentPath;
36
+
37
+ var documentPath = useFetchDocumentPath(client, document);
38
+ var sharedParentPath = documentPath && getSharedParentPath(documentPath);
39
+ var parentFolderName = sharedParentPath === null || sharedParentPath === void 0 ? void 0 : sharedParentPath.split('/').pop();
40
+ var contactName = getDisplayName(recipient, t(DEFAULT_DISPLAY_NAME));
41
+
42
+ var _snarkdown$split = snarkdown(t('DowngradePermissionConfirmDialog.content', {
43
+ contactName: contactName
44
+ })).split('<strong>'),
45
+ _snarkdown$split2 = _slicedToArray(_snarkdown$split, 2),
46
+ before = _snarkdown$split2[0],
47
+ afterOpen = _snarkdown$split2[1];
48
+
49
+ var _afterOpen$split = afterOpen.split('</strong>'),
50
+ _afterOpen$split2 = _slicedToArray(_afterOpen$split, 2),
51
+ boldContactName = _afterOpen$split2[0],
52
+ after = _afterOpen$split2[1];
53
+
54
+ return /*#__PURE__*/React.createElement(ConfirmDialog, {
55
+ open: true,
56
+ title: t('DowngradePermissionConfirmDialog.title'),
57
+ content: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Typography, null, before, /*#__PURE__*/React.createElement("strong", null, boldContactName), after), /*#__PURE__*/React.createElement(Timeline, null, /*#__PURE__*/React.createElement(TimelineItem, null, /*#__PURE__*/React.createElement(TimelineOppositeContent, {
58
+ style: {
59
+ display: 'none'
60
+ }
61
+ }), /*#__PURE__*/React.createElement(TimelineSeparator, null, /*#__PURE__*/React.createElement(TimelineDot, {
62
+ className: "u-bg-transparent u-bdw-0 u-elevation-0 u-p-0"
63
+ }, /*#__PURE__*/React.createElement("div", {
64
+ className: "u-w-1-half u-flex u-flex-justify-center"
65
+ }, /*#__PURE__*/React.createElement(Icon, {
66
+ icon: FolderOutlined,
67
+ size: 24
68
+ }))), /*#__PURE__*/React.createElement(TimelineConnector, null)), /*#__PURE__*/React.createElement(TimelineContent, null, /*#__PURE__*/React.createElement(Typography, {
69
+ variant: "body2"
70
+ }, parentFolderName), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("s", null, /*#__PURE__*/React.createElement(Typography, {
71
+ display: "inline",
72
+ variant: "caption"
73
+ }, t('Share.type.two-way'))), /*#__PURE__*/React.createElement(Icon, {
74
+ className: "u-mh-half",
75
+ color: "var(--secondaryTextColor)",
76
+ icon: ArrowUp,
77
+ rotate: 90,
78
+ size: 12
79
+ }), /*#__PURE__*/React.createElement(Typography, {
80
+ display: "inline",
81
+ variant: "caption"
82
+ }, t('Share.type.one-way'))))), /*#__PURE__*/React.createElement(TimelineItem, null, /*#__PURE__*/React.createElement(TimelineOppositeContent, {
83
+ style: {
84
+ display: 'none'
85
+ }
86
+ }), /*#__PURE__*/React.createElement(TimelineSeparator, null, /*#__PURE__*/React.createElement(TimelineDot, {
87
+ className: "u-bg-transparent u-bdw-0 u-elevation-0 u-p-0"
88
+ }, /*#__PURE__*/React.createElement("div", {
89
+ className: "u-w-1-half u-flex u-flex-justify-center"
90
+ }, /*#__PURE__*/React.createElement(Icon, {
91
+ icon: FolderOutlined,
92
+ size: 16
93
+ })))), /*#__PURE__*/React.createElement(TimelineContent, null, /*#__PURE__*/React.createElement(Typography, {
94
+ variant: "body2"
95
+ }, document.name), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("s", null, /*#__PURE__*/React.createElement(Typography, {
96
+ display: "inline",
97
+ variant: "caption"
98
+ }, t('Share.type.two-way'))), /*#__PURE__*/React.createElement(Icon, {
99
+ className: "u-mh-half",
100
+ color: "var(--secondaryTextColor)",
101
+ icon: ArrowUp,
102
+ rotate: 90,
103
+ size: 12
104
+ }), /*#__PURE__*/React.createElement(Typography, {
105
+ display: "inline",
106
+ variant: "caption"
107
+ }, t('Share.type.one-way'))))))),
108
+ actions: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Button, {
109
+ variant: "secondary",
110
+ label: t('DowngradePermissionConfirmDialog.cancel'),
111
+ onClick: onCancel
112
+ }), /*#__PURE__*/React.createElement(Button, {
113
+ variant: "primary",
114
+ label: t('DowngradePermissionConfirmDialog.confirm'),
115
+ onClick: onConfirm
116
+ })),
117
+ onClose: onCancel
118
+ });
119
+ };
120
+
121
+ DowngradePermissionConfirmDialog.propTypes = {
122
+ document: PropTypes.object.isRequired,
123
+ recipient: PropTypes.object.isRequired,
124
+ onCancel: PropTypes.func.isRequired,
125
+ onConfirm: PropTypes.func.isRequired
126
+ };
127
+ export default DowngradePermissionConfirmDialog;
@@ -1,3 +1,4 @@
1
+ import _extends from "@babel/runtime/helpers/extends";
1
2
  import _objectWithoutProperties from "@babel/runtime/helpers/objectWithoutProperties";
2
3
  var _excluded = ["instance", "isOwner", "status", "recipientConfirmationData", "verifyRecipient", "fadeIn", "isOrgSharedDrive"];
3
4
  import PropTypes from 'prop-types';
@@ -41,7 +42,11 @@ var MemberRecipient = function MemberRecipient(props) {
41
42
  var RightPart = recipientConfirmationData ? /*#__PURE__*/React.createElement(RecipientConfirm, {
42
43
  recipientConfirmationData: recipientConfirmationData,
43
44
  verifyRecipient: verifyRecipient
44
- }) : /*#__PURE__*/React.createElement(MemberRecipientPermissions, props);
45
+ }) : /*#__PURE__*/React.createElement(MemberRecipientPermissions, _extends({}, rest, {
46
+ instance: instance,
47
+ isOwner: isOwner,
48
+ status: status
49
+ }));
45
50
  return /*#__PURE__*/React.createElement(Fade, {
46
51
  in: true,
47
52
  timeout: fadeIn ? FADE_IN_DURATION : 0
@@ -1,5 +1,7 @@
1
1
  import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator";
2
2
  import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
3
+ import _objectWithoutProperties from "@babel/runtime/helpers/objectWithoutProperties";
4
+ var _excluded = ["isOwner", "canManageSharing", "canManageMembers", "status", "instance", "type", "document", "onRevoke", "onRevokeSelf", "sharingId", "memberIndex"];
3
5
  import _regeneratorRuntime from "@babel/runtime/regenerator";
4
6
  import React, { useState, useCallback } from 'react';
5
7
  import { Icon, CrossCircleOutline } from '@linagora/twake-icons';
@@ -23,7 +25,8 @@ var MemberRecipientPermissions = function MemberRecipientPermissions(_ref) {
23
25
  onRevoke = _ref.onRevoke,
24
26
  onRevokeSelf = _ref.onRevokeSelf,
25
27
  sharingId = _ref.sharingId,
26
- memberIndex = _ref.memberIndex;
28
+ memberIndex = _ref.memberIndex,
29
+ recipient = _objectWithoutProperties(_ref, _excluded);
27
30
 
28
31
  var _useI18n = useI18n(),
29
32
  t = _useI18n.t;
@@ -77,7 +80,9 @@ var MemberRecipientPermissions = function MemberRecipientPermissions(_ref) {
77
80
  }, contactIsOwner ? t("Share.status.".concat(status)) : t("Share.type.".concat(type))), shouldShowMenu && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(PermissionTypeMenu, {
78
81
  sharingId: sharingId,
79
82
  memberIndex: memberIndex,
80
- type: type !== null && type !== void 0 ? type : 'one-way'
83
+ type: type !== null && type !== void 0 ? type : 'one-way',
84
+ document: document,
85
+ recipient: recipient
81
86
  }), /*#__PURE__*/React.createElement(IconButton, {
82
87
  onClick: handleRevocation,
83
88
  size: "small",
@@ -2,29 +2,36 @@ import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator";
2
2
  import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
3
3
  import _regeneratorRuntime from "@babel/runtime/regenerator";
4
4
  import React, { useCallback, useRef, useState } from 'react';
5
+ import { useClient } from 'cozy-client';
5
6
  import minilog from 'cozy-minilog';
6
7
  import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu';
7
8
  import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions';
8
9
  import DropdownButton from 'cozy-ui/transpiled/react/DropdownButton';
9
10
  import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert';
10
11
  import { useI18n } from 'twake-i18n';
12
+ import DowngradePermissionConfirmDialog from './DowngradePermissionConfirmDialog';
11
13
  import { setReadOnlySharedPermission } from './actions/setReadOnlySharedPermission';
12
14
  import { setReadWriteSharedPermission } from './actions/setReadWriteSharedPermission';
13
15
  import withLocales from '../../hoc/withLocales';
16
+ import { useFetchDocumentPath } from '../../hooks/useFetchDocumentPath';
14
17
  import { useSharingContext } from '../../hooks/useSharingContext';
15
18
  var log = minilog('PermissionTypeMenu');
16
19
 
17
20
  var PermissionTypeMenuComponent = function PermissionTypeMenuComponent(_ref) {
18
21
  var sharingId = _ref.sharingId,
19
22
  memberIndex = _ref.memberIndex,
20
- type = _ref.type;
23
+ type = _ref.type,
24
+ document = _ref.document,
25
+ recipient = _ref.recipient;
21
26
 
22
27
  var _useI18n = useI18n(),
23
28
  t = _useI18n.t;
24
29
 
30
+ var client = useClient();
25
31
  var buttonRef = useRef();
26
32
 
27
33
  var _useSharingContext = useSharingContext(),
34
+ hasSharedParent = _useSharingContext.hasSharedParent,
28
35
  updateSharingMemberType = _useSharingContext.updateSharingMemberType;
29
36
 
30
37
  var _useAlert = useAlert(),
@@ -35,32 +42,31 @@ var PermissionTypeMenuComponent = function PermissionTypeMenuComponent(_ref) {
35
42
  isMenuDisplayed = _useState2[0],
36
43
  setMenuDisplayed = _useState2[1];
37
44
 
45
+ var _useState3 = useState(null),
46
+ _useState4 = _slicedToArray(_useState3, 2),
47
+ pendingType = _useState4[0],
48
+ setPendingType = _useState4[1];
49
+
50
+ var documentPath = useFetchDocumentPath(client, document);
38
51
  var hideMenu = useCallback(function () {
39
52
  setMenuDisplayed(false);
40
53
  }, []);
41
- var setType = useCallback( /*#__PURE__*/function () {
54
+ var applyType = useCallback( /*#__PURE__*/function () {
42
55
  var _ref2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee(newType) {
43
56
  return _regeneratorRuntime.wrap(function _callee$(_context) {
44
57
  while (1) switch (_context.prev = _context.next) {
45
58
  case 0:
46
- hideMenu();
47
-
48
- if (!(newType !== type)) {
49
- _context.next = 11;
50
- break;
51
- }
52
-
53
- _context.prev = 2;
54
- _context.next = 5;
59
+ _context.prev = 0;
60
+ _context.next = 3;
55
61
  return updateSharingMemberType(sharingId, memberIndex, newType);
56
62
 
57
- case 5:
58
- _context.next = 11;
63
+ case 3:
64
+ _context.next = 9;
59
65
  break;
60
66
 
61
- case 7:
62
- _context.prev = 7;
63
- _context.t0 = _context["catch"](2);
67
+ case 5:
68
+ _context.prev = 5;
69
+ _context.t0 = _context["catch"](0);
64
70
  log.error('Failed to change member permission type', _context.t0);
65
71
  showAlert({
66
72
  message: t('Share.members.error.changePermission'),
@@ -68,17 +74,39 @@ var PermissionTypeMenuComponent = function PermissionTypeMenuComponent(_ref) {
68
74
  variant: 'filled'
69
75
  });
70
76
 
71
- case 11:
77
+ case 9:
72
78
  case "end":
73
79
  return _context.stop();
74
80
  }
75
- }, _callee, null, [[2, 7]]);
81
+ }, _callee, null, [[0, 5]]);
76
82
  }));
77
83
 
78
84
  return function (_x) {
79
85
  return _ref2.apply(this, arguments);
80
86
  };
81
- }(), [hideMenu, memberIndex, sharingId, showAlert, t, type, updateSharingMemberType]);
87
+ }(), [memberIndex, sharingId, showAlert, t, updateSharingMemberType]); // Downgrading to viewer on a folder inside a shared parent also reduces
88
+ // the member's rights on the parent: require explicit confirmation first.
89
+
90
+ var shouldConfirmDowngrade = type === 'two-way' && Boolean(documentPath) && (hasSharedParent === null || hasSharedParent === void 0 ? void 0 : hasSharedParent(documentPath));
91
+ var setType = useCallback(function (newType) {
92
+ hideMenu();
93
+ if (newType === type) return;
94
+
95
+ if (newType === 'one-way' && shouldConfirmDowngrade) {
96
+ setPendingType(newType);
97
+ return;
98
+ }
99
+
100
+ applyType(newType);
101
+ }, [applyType, hideMenu, shouldConfirmDowngrade, type]);
102
+ var handleConfirmDowngrade = useCallback(function () {
103
+ var newType = pendingType;
104
+ setPendingType(null);
105
+ if (newType) applyType(newType);
106
+ }, [applyType, pendingType]);
107
+ var handleCancelDowngrade = useCallback(function () {
108
+ setPendingType(null);
109
+ }, []);
82
110
  var actions = makeActions([setReadOnlySharedPermission, setReadWriteSharedPermission], {
83
111
  t: t,
84
112
  type: type !== null && type !== void 0 ? type : 'one-way',
@@ -98,6 +126,11 @@ var PermissionTypeMenuComponent = function PermissionTypeMenuComponent(_ref) {
98
126
  actions: actions,
99
127
  autoClose: true,
100
128
  onClose: hideMenu
129
+ }), pendingType && /*#__PURE__*/React.createElement(DowngradePermissionConfirmDialog, {
130
+ document: document,
131
+ recipient: recipient,
132
+ onCancel: handleCancelDowngrade,
133
+ onConfirm: handleConfirmDowngrade
101
134
  }));
102
135
  };
103
136
 
@@ -7,11 +7,15 @@ import { createMockClient } from 'cozy-client';
7
7
  import { PermissionTypeMenu } from './PermissionTypeMenu';
8
8
  import AppLike from '../../../test/AppLike';
9
9
  var mockUpdateSharingMemberType = jest.fn();
10
+ var mockHasSharedParent = jest.fn();
11
+ var mockGetSharedParentPath = jest.fn();
10
12
  var mockShowAlert = jest.fn();
11
13
  jest.mock('../../hooks/useSharingContext', function () {
12
14
  return {
13
15
  useSharingContext: function useSharingContext() {
14
16
  return {
17
+ hasSharedParent: mockHasSharedParent,
18
+ getSharedParentPath: mockGetSharedParentPath,
15
19
  updateSharingMemberType: mockUpdateSharingMemberType
16
20
  };
17
21
  }
@@ -39,7 +43,10 @@ describe('PermissionTypeMenu component', function () {
39
43
  var defaultProps = {
40
44
  sharingId: 'sharing-123',
41
45
  memberIndex: 1,
42
- type: 'two-way'
46
+ type: 'two-way',
47
+ recipient: {
48
+ name: 'Alice'
49
+ }
43
50
  };
44
51
  beforeEach(function () {
45
52
  jest.clearAllMocks();
@@ -177,4 +184,106 @@ describe('PermissionTypeMenu component', function () {
177
184
  }
178
185
  }, _callee4);
179
186
  })));
187
+ describe('when downgrading to viewer on a folder with a shared parent', function () {
188
+ var folderDocument = {
189
+ _id: 'doc-1',
190
+ type: 'directory',
191
+ path: '/parent/child'
192
+ };
193
+ beforeEach(function () {
194
+ mockHasSharedParent.mockReturnValue(true);
195
+ });
196
+ it('should ask for confirmation instead of updating directly', function () {
197
+ var _setup5 = setup({
198
+ type: 'two-way',
199
+ document: folderDocument
200
+ }),
201
+ getByRole = _setup5.getByRole;
202
+
203
+ fireEvent.click(getByRole('button', {
204
+ name: 'Editor'
205
+ }));
206
+ fireEvent.click(getByRole('menuitem', {
207
+ name: 'Viewer'
208
+ }));
209
+ expect(mockUpdateSharingMemberType).not.toHaveBeenCalled();
210
+ expect(getByRole('button', {
211
+ name: 'Update parent'
212
+ })).toBeInTheDocument();
213
+ });
214
+ it('should call updateSharingMemberType when confirming', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee5() {
215
+ var _setup6, getByRole;
216
+
217
+ return _regeneratorRuntime.wrap(function _callee5$(_context5) {
218
+ while (1) switch (_context5.prev = _context5.next) {
219
+ case 0:
220
+ mockUpdateSharingMemberType.mockResolvedValue(undefined);
221
+ _setup6 = setup({
222
+ type: 'two-way',
223
+ document: folderDocument
224
+ }), getByRole = _setup6.getByRole;
225
+ fireEvent.click(getByRole('button', {
226
+ name: 'Editor'
227
+ }));
228
+ fireEvent.click(getByRole('menuitem', {
229
+ name: 'Viewer'
230
+ }));
231
+ fireEvent.click(getByRole('button', {
232
+ name: 'Update parent'
233
+ }));
234
+ _context5.next = 7;
235
+ return waitFor(function () {
236
+ expect(mockUpdateSharingMemberType).toHaveBeenCalledWith('sharing-123', 1, 'one-way');
237
+ });
238
+
239
+ case 7:
240
+ case "end":
241
+ return _context5.stop();
242
+ }
243
+ }, _callee5);
244
+ })));
245
+ it('should not call updateSharingMemberType when cancelling', function () {
246
+ var _setup7 = setup({
247
+ type: 'two-way',
248
+ document: folderDocument
249
+ }),
250
+ getByRole = _setup7.getByRole,
251
+ queryByRole = _setup7.queryByRole;
252
+
253
+ fireEvent.click(getByRole('button', {
254
+ name: 'Editor'
255
+ }));
256
+ fireEvent.click(getByRole('menuitem', {
257
+ name: 'Viewer'
258
+ }));
259
+ fireEvent.click(getByRole('button', {
260
+ name: 'Cancel'
261
+ }));
262
+ expect(mockUpdateSharingMemberType).not.toHaveBeenCalled();
263
+ expect(queryByRole('button', {
264
+ name: 'Update parent'
265
+ })).toBe(null);
266
+ });
267
+ it('should update directly when the folder has no shared parent', function () {
268
+ mockHasSharedParent.mockReturnValue(false);
269
+
270
+ var _setup8 = setup({
271
+ type: 'two-way',
272
+ document: folderDocument
273
+ }),
274
+ getByRole = _setup8.getByRole,
275
+ queryByRole = _setup8.queryByRole;
276
+
277
+ fireEvent.click(getByRole('button', {
278
+ name: 'Editor'
279
+ }));
280
+ fireEvent.click(getByRole('menuitem', {
281
+ name: 'Viewer'
282
+ }));
283
+ expect(mockUpdateSharingMemberType).toHaveBeenCalledWith('sharing-123', 1, 'one-way');
284
+ expect(queryByRole('button', {
285
+ name: 'Update parent'
286
+ })).toBe(null);
287
+ });
288
+ });
180
289
  });
@@ -1,31 +1,8 @@
1
- import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
2
- import _createClass from "@babel/runtime/helpers/createClass";
3
- import _possibleConstructorReturn from "@babel/runtime/helpers/possibleConstructorReturn";
4
- import _getPrototypeOf from "@babel/runtime/helpers/getPrototypeOf";
5
- import _inherits from "@babel/runtime/helpers/inherits";
6
- import _defineProperty from "@babel/runtime/helpers/defineProperty";
7
1
  import _extends from "@babel/runtime/helpers/extends";
8
-
9
- function _callSuper(_this, derived, args) {
10
- function isNativeReflectConstruct() {
11
- if (typeof Reflect === "undefined" || !Reflect.construct) return false;
12
- if (Reflect.construct.sham) return false;
13
- if (typeof Proxy === "function") return true;
14
-
15
- try {
16
- return !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {}));
17
- } catch (e) {
18
- return false;
19
- }
20
- }
21
-
22
- derived = _getPrototypeOf(derived);
23
- return _possibleConstructorReturn(_this, isNativeReflectConstruct() ? Reflect.construct(derived, args || [], _getPrototypeOf(_this).constructor) : derived.apply(_this, args));
24
- }
25
-
26
2
  import PropTypes from 'prop-types';
27
3
  import React from 'react';
28
4
  import ReactTooltip from 'react-tooltip';
5
+ import { useI18n } from 'twake-i18n';
29
6
  var styles = {
30
7
  "shared-tooltip": "tooltip__shared-tooltip___7Dys3",
31
8
  "shared-tooltip-list": "tooltip__shared-tooltip-list___jD5hi"
@@ -38,40 +15,23 @@ export var SharingTooltip = function SharingTooltip(props) {
38
15
  }, props), props.children);
39
16
  }; // accepts all the props from https://github.com/wwayne/react-tooltip#options
40
17
 
41
- export var TooltipRecipientList = /*#__PURE__*/function (_React$Component) {
42
- function TooltipRecipientList() {
43
- _classCallCheck(this, TooltipRecipientList);
44
-
45
- return _callSuper(this, TooltipRecipientList, arguments);
46
- }
47
-
48
- _inherits(TooltipRecipientList, _React$Component);
49
-
50
- return _createClass(TooltipRecipientList, [{
51
- key: "render",
52
- value: function render() {
53
- var t = this.context.t;
54
- var _this$props = this.props,
55
- recipientNames = _this$props.recipientNames,
56
- _this$props$cutoff = _this$props.cutoff,
57
- cutoff = _this$props$cutoff === void 0 ? 4 : _this$props$cutoff;
58
- return /*#__PURE__*/React.createElement("ul", {
59
- className: styles['shared-tooltip-list']
60
- }, recipientNames.slice(0, cutoff).map(function (name, i) {
61
- return /*#__PURE__*/React.createElement("li", {
62
- key: "key_name_".concat(name, "_").concat(i)
63
- }, name);
64
- }), recipientNames.length > cutoff && /*#__PURE__*/React.createElement("li", null, t('Share.members.others', {
65
- smart_count: recipientNames.slice(cutoff).length
66
- })));
67
- }
68
- }]);
69
- }(React.Component);
70
-
71
- _defineProperty(TooltipRecipientList, "contextTypes", {
72
- t: PropTypes.func.isRequired
73
- });
74
-
18
+ export var TooltipRecipientList = function TooltipRecipientList(_ref) {
19
+ var recipientNames = _ref.recipientNames,
20
+ cutoff = _ref.cutoff;
21
+
22
+ var _useI18n = useI18n(),
23
+ t = _useI18n.t;
24
+
25
+ return /*#__PURE__*/React.createElement("ul", {
26
+ className: styles['shared-tooltip-list']
27
+ }, recipientNames.slice(0, cutoff).map(function (name, i) {
28
+ return /*#__PURE__*/React.createElement("li", {
29
+ key: "key_name_".concat(name, "_").concat(i)
30
+ }, name);
31
+ }), recipientNames.length > cutoff && /*#__PURE__*/React.createElement("li", null, t('Share.members.others', {
32
+ smart_count: recipientNames.slice(cutoff).length
33
+ })));
34
+ };
75
35
  TooltipRecipientList.propTypes = {
76
36
  recipientNames: PropTypes.arrayOf(PropTypes.string),
77
37
  cutoff: PropTypes.number
@@ -4,7 +4,7 @@ import _regeneratorRuntime from "@babel/runtime/regenerator";
4
4
  import { useState, useEffect } from 'react';
5
5
  import { fetchFilesPaths } from '../helpers/files';
6
6
  export var useFetchDocumentPath = function useFetchDocumentPath(client, document) {
7
- var _useState = useState(document.path ? document.path : null),
7
+ var _useState = useState(document !== null && document !== void 0 && document.path ? document.path : null),
8
8
  _useState2 = _slicedToArray(_useState, 2),
9
9
  documentPath = _useState2[0],
10
10
  setDocumentPath = _useState2[1];
@@ -18,35 +18,44 @@ export var useFetchDocumentPath = function useFetchDocumentPath(client, document
18
18
  while (1) switch (_context.prev = _context.next) {
19
19
  case 0:
20
20
  _context.prev = 0;
21
+
22
+ if (document) {
23
+ _context.next = 3;
24
+ break;
25
+ }
26
+
27
+ return _context.abrupt("return", setDocumentPath(null));
28
+
29
+ case 3:
21
30
  isDirectory = document.type === 'directory';
22
31
 
23
32
  if (!isDirectory) {
24
- _context.next = 4;
33
+ _context.next = 6;
25
34
  break;
26
35
  }
27
36
 
28
37
  return _context.abrupt("return", setDocumentPath(document.path));
29
38
 
30
- case 4:
31
- _context.next = 6;
39
+ case 6:
40
+ _context.next = 8;
32
41
  return fetchFilesPaths(client, document._type, [document]);
33
42
 
34
- case 6:
43
+ case 8:
35
44
  path = _context.sent;
36
45
  setDocumentPath(path[0]); // eslint-disable-next-line
37
46
 
38
- _context.next = 12;
47
+ _context.next = 14;
39
48
  break;
40
49
 
41
- case 10:
42
- _context.prev = 10;
50
+ case 12:
51
+ _context.prev = 12;
43
52
  _context.t0 = _context["catch"](0);
44
53
 
45
- case 12:
54
+ case 14:
46
55
  case "end":
47
56
  return _context.stop();
48
57
  }
49
- }, _callee, null, [[0, 10]]);
58
+ }, _callee, null, [[0, 12]]);
50
59
  }))();
51
60
  }, [client, document]);
52
61
  return documentPath;
package/dist/state.js CHANGED
@@ -700,7 +700,7 @@ export var hasSharedChild = function hasSharedChild(state, documentPath) {
700
700
  export var getSharedParentPath = function getSharedParentPath(state, documentPath) {
701
701
  if (hasSharedParent(state, documentPath)) {
702
702
  return state.sharedPaths.find(function (path) {
703
- return documentPath.startsWith(path);
703
+ return documentPath.startsWith("".concat(path, "/"));
704
704
  });
705
705
  }
706
706
 
package/locales/en.json CHANGED
@@ -386,6 +386,12 @@
386
386
  "cancel": "Cancel",
387
387
  "discard": "Discard"
388
388
  },
389
+ "DowngradePermissionConfirmDialog": {
390
+ "title": "Update role for parent folder?",
391
+ "content": "Changing **%{contactName}** permissions on this item will aslo change permissions on a parent folder. Alternatively, apply limited access to this folder.",
392
+ "cancel": "Cancel",
393
+ "confirm": "Update parent"
394
+ },
389
395
  "GroupRecipient": {
390
396
  "secondary": "%{memberCount} member |||| %{memberCount} members",
391
397
  "secondary_you": "including you"
package/locales/fr.json CHANGED
@@ -384,6 +384,12 @@
384
384
  "cancel": "Annuler",
385
385
  "discard": "Abandonner"
386
386
  },
387
+ "DowngradePermissionConfirmDialog": {
388
+ "title": "Modifier le rôle du dossier parent ?",
389
+ "content": "Modifier les permissions de **%{contactName}** sur cet élément modifiera également les permissions sur un dossier parent. Vous pouvez aussi appliquer un accès limité à ce dossier.",
390
+ "cancel": "Annuler",
391
+ "confirm": "Mettre à jour le parent"
392
+ },
387
393
  "GroupRecipient": {
388
394
  "secondary": "%{memberCount} membre |||| %{memberCount} membres",
389
395
  "secondary_you": "dont vous"
package/locales/ru.json CHANGED
@@ -386,6 +386,12 @@
386
386
  "cancel": "Отмена",
387
387
  "discard": "Отменить"
388
388
  },
389
+ "DowngradePermissionConfirmDialog": {
390
+ "title": "Обновить роль родительской папки?",
391
+ "content": "Изменение прав доступа **%{contactName}** для этого элемента также изменит права доступа для родительской папки. Либо примените ограниченный доступ к этой папке.",
392
+ "cancel": "Отмена",
393
+ "confirm": "Обновить родительскую папку"
394
+ },
389
395
  "GroupRecipient": {
390
396
  "secondary": "%{memberCount} участник |||| %{memberCount} участников",
391
397
  "secondary_you": "включая вас"
package/locales/vi.json CHANGED
@@ -386,6 +386,12 @@
386
386
  "cancel": "Hủy",
387
387
  "discard": "Bỏ"
388
388
  },
389
+ "DowngradePermissionConfirmDialog": {
390
+ "title": "Cập nhật vai trò cho thư mục cha?",
391
+ "content": "Việc thay đổi quyền của **%{contactName}** trên mục này cũng sẽ thay đổi quyền trên thư mục cha. Ngoài ra, bạn có thể áp dụng quyền truy cập giới hạn cho thư mục này.",
392
+ "cancel": "Hủy",
393
+ "confirm": "Cập nhật thư mục cha"
394
+ },
389
395
  "GroupRecipient": {
390
396
  "secondary": "%{memberCount} thành viên |||| %{memberCount} thành viên",
391
397
  "secondary_you": "bao gồm cả bạn"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-sharing",
3
- "version": "37.6.1",
3
+ "version": "37.7.0",
4
4
  "description": "Provides sharing login for React applications.",
5
5
  "main": "dist/index.js",
6
6
  "author": "Cozy",
@@ -59,14 +59,14 @@
59
59
  "cozy-intent": "^2.31.1",
60
60
  "cozy-minilog": "^3.10.1",
61
61
  "cozy-ui": "^140.5.0",
62
- "cozy-ui-plus": "^13.0.0",
62
+ "cozy-ui-plus": "^13.0.2",
63
63
  "jest": "30.3.0",
64
64
  "jest-environment-jsdom": "30.3.0",
65
65
  "react": "16.12.0",
66
66
  "react-dom": "16.13.0",
67
67
  "react-router": "^5.0.1",
68
68
  "storybook": "7.6.0",
69
- "twake-i18n": "^0.4.1"
69
+ "twake-i18n": "^0.5.0"
70
70
  },
71
71
  "peerDependencies": {
72
72
  "@linagora/twake-icons": ">=2.6.6",
@@ -85,5 +85,5 @@
85
85
  "sideEffects": [
86
86
  "*.css"
87
87
  ],
88
- "gitHead": "b101bc4cb905b32920ef7a22344eb890147eab23"
88
+ "gitHead": "b204dc35cf01f135f6b8ba4a5ae4acbb0753456f"
89
89
  }
@@ -1,28 +1,29 @@
1
- import PropTypes from 'prop-types'
2
1
  import React from 'react'
3
2
 
3
+ import { useI18n } from 'twake-i18n'
4
+
4
5
  import Identity from './Identity'
5
6
  import { getDisplayName } from '../../models'
6
7
  import styles from '../../styles/recipient.styl'
7
8
  import { ExtraAvatar } from '../Avatar/ExtraAvatar'
8
9
 
9
- const ExtraIdentity = ({ extraRecipients }, { t }) => (
10
- <div className={styles['recipient']}>
11
- <ExtraAvatar
12
- extraRecipients={extraRecipients.map(recipient =>
13
- getDisplayName(recipient)
14
- )}
15
- />
16
- <div className={styles['recipient-ident-status']}>
17
- <Identity
18
- name={t('Share.members.otherContacts', extraRecipients.length)}
10
+ const ExtraIdentity = ({ extraRecipients }) => {
11
+ const { t } = useI18n()
12
+
13
+ return (
14
+ <div className={styles['recipient']}>
15
+ <ExtraAvatar
16
+ extraRecipients={extraRecipients.map(recipient =>
17
+ getDisplayName(recipient)
18
+ )}
19
19
  />
20
+ <div className={styles['recipient-ident-status']}>
21
+ <Identity
22
+ name={t('Share.members.otherContacts', extraRecipients.length)}
23
+ />
24
+ </div>
20
25
  </div>
21
- </div>
22
- )
23
-
24
- ExtraIdentity.contextTypes = {
25
- t: PropTypes.func.isRequired
26
+ )
26
27
  }
27
28
 
28
29
  export { ExtraIdentity }
@@ -0,0 +1,146 @@
1
+ import PropTypes from 'prop-types'
2
+ import React from 'react'
3
+ import snarkdown from 'snarkdown'
4
+
5
+ import { Icon, FolderOutlined, ArrowUp } from '@linagora/twake-icons'
6
+ import { useClient } from 'cozy-client'
7
+ import Button from 'cozy-ui/transpiled/react/Buttons'
8
+ import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'
9
+ import Timeline from 'cozy-ui/transpiled/react/Timeline'
10
+ import TimelineConnector from 'cozy-ui/transpiled/react/TimelineConnector'
11
+ import TimelineContent from 'cozy-ui/transpiled/react/TimelineContent'
12
+ import TimelineDot from 'cozy-ui/transpiled/react/TimelineDot'
13
+ import TimelineItem from 'cozy-ui/transpiled/react/TimelineItem'
14
+ import TimelineOppositeContent from 'cozy-ui/transpiled/react/TimelineOppositeContent'
15
+ import TimelineSeparator from 'cozy-ui/transpiled/react/TimelineSeparator'
16
+ import Typography from 'cozy-ui/transpiled/react/Typography'
17
+ import { useI18n } from 'twake-i18n'
18
+
19
+ import { DEFAULT_DISPLAY_NAME } from '../../helpers/recipients'
20
+ import { useFetchDocumentPath } from '../../hooks/useFetchDocumentPath'
21
+ import { useSharingContext } from '../../hooks/useSharingContext'
22
+ import { getDisplayName } from '../../models'
23
+
24
+ const DowngradePermissionConfirmDialog = ({
25
+ document,
26
+ recipient,
27
+ onCancel,
28
+ onConfirm
29
+ }) => {
30
+ const { t } = useI18n()
31
+ const client = useClient()
32
+ const { getSharedParentPath } = useSharingContext()
33
+
34
+ const documentPath = useFetchDocumentPath(client, document)
35
+ const sharedParentPath = documentPath && getSharedParentPath(documentPath)
36
+ const parentFolderName = sharedParentPath?.split('/').pop()
37
+ const contactName = getDisplayName(recipient, t(DEFAULT_DISPLAY_NAME))
38
+ const [before, afterOpen] = snarkdown(
39
+ t('DowngradePermissionConfirmDialog.content', { contactName })
40
+ ).split('<strong>')
41
+ const [boldContactName, after] = afterOpen.split('</strong>')
42
+
43
+ return (
44
+ <ConfirmDialog
45
+ open
46
+ title={t('DowngradePermissionConfirmDialog.title')}
47
+ content={
48
+ <>
49
+ <Typography>
50
+ {before}
51
+ <strong>{boldContactName}</strong>
52
+ {after}
53
+ </Typography>
54
+
55
+ <Timeline>
56
+ <TimelineItem>
57
+ <TimelineOppositeContent style={{ display: 'none' }} />
58
+ <TimelineSeparator>
59
+ <TimelineDot className="u-bg-transparent u-bdw-0 u-elevation-0 u-p-0">
60
+ <div className="u-w-1-half u-flex u-flex-justify-center">
61
+ <Icon icon={FolderOutlined} size={24} />
62
+ </div>
63
+ </TimelineDot>
64
+ <TimelineConnector />
65
+ </TimelineSeparator>
66
+ <TimelineContent>
67
+ <Typography variant="body2">{parentFolderName}</Typography>
68
+ <div>
69
+ <s>
70
+ <Typography display="inline" variant="caption">
71
+ {t('Share.type.two-way')}
72
+ </Typography>
73
+ </s>
74
+ <Icon
75
+ className="u-mh-half"
76
+ color="var(--secondaryTextColor)"
77
+ icon={ArrowUp}
78
+ rotate={90}
79
+ size={12}
80
+ />
81
+ <Typography display="inline" variant="caption">
82
+ {t('Share.type.one-way')}
83
+ </Typography>
84
+ </div>
85
+ </TimelineContent>
86
+ </TimelineItem>
87
+ <TimelineItem>
88
+ <TimelineOppositeContent style={{ display: 'none' }} />
89
+ <TimelineSeparator>
90
+ <TimelineDot className="u-bg-transparent u-bdw-0 u-elevation-0 u-p-0">
91
+ <div className="u-w-1-half u-flex u-flex-justify-center">
92
+ <Icon icon={FolderOutlined} size={16} />
93
+ </div>
94
+ </TimelineDot>
95
+ </TimelineSeparator>
96
+ <TimelineContent>
97
+ <Typography variant="body2">{document.name}</Typography>
98
+ <div>
99
+ <s>
100
+ <Typography display="inline" variant="caption">
101
+ {t('Share.type.two-way')}
102
+ </Typography>
103
+ </s>
104
+ <Icon
105
+ className="u-mh-half"
106
+ color="var(--secondaryTextColor)"
107
+ icon={ArrowUp}
108
+ rotate={90}
109
+ size={12}
110
+ />
111
+ <Typography display="inline" variant="caption">
112
+ {t('Share.type.one-way')}
113
+ </Typography>
114
+ </div>
115
+ </TimelineContent>
116
+ </TimelineItem>
117
+ </Timeline>
118
+ </>
119
+ }
120
+ actions={
121
+ <>
122
+ <Button
123
+ variant="secondary"
124
+ label={t('DowngradePermissionConfirmDialog.cancel')}
125
+ onClick={onCancel}
126
+ />
127
+ <Button
128
+ variant="primary"
129
+ label={t('DowngradePermissionConfirmDialog.confirm')}
130
+ onClick={onConfirm}
131
+ />
132
+ </>
133
+ }
134
+ onClose={onCancel}
135
+ />
136
+ )
137
+ }
138
+
139
+ DowngradePermissionConfirmDialog.propTypes = {
140
+ document: PropTypes.object.isRequired,
141
+ recipient: PropTypes.object.isRequired,
142
+ onCancel: PropTypes.func.isRequired,
143
+ onConfirm: PropTypes.func.isRequired
144
+ }
145
+
146
+ export default DowngradePermissionConfirmDialog
@@ -47,7 +47,12 @@ const MemberRecipient = props => {
47
47
  verifyRecipient={verifyRecipient}
48
48
  />
49
49
  ) : (
50
- <MemberRecipientPermissions {...props} />
50
+ <MemberRecipientPermissions
51
+ {...rest}
52
+ instance={instance}
53
+ isOwner={isOwner}
54
+ status={status}
55
+ />
51
56
  )
52
57
 
53
58
  return (
@@ -20,7 +20,8 @@ const MemberRecipientPermissions = ({
20
20
  onRevoke,
21
21
  onRevokeSelf,
22
22
  sharingId,
23
- memberIndex
23
+ memberIndex,
24
+ ...recipient
24
25
  }) => {
25
26
  const { t } = useI18n()
26
27
  const client = useClient()
@@ -73,6 +74,8 @@ const MemberRecipientPermissions = ({
73
74
  sharingId={sharingId}
74
75
  memberIndex={memberIndex}
75
76
  type={type ?? 'one-way'}
77
+ document={document}
78
+ recipient={recipient}
76
79
  />
77
80
  <IconButton
78
81
  onClick={handleRevocation}
@@ -1,5 +1,6 @@
1
1
  import React, { useCallback, useRef, useState } from 'react'
2
2
 
3
+ import { useClient } from 'cozy-client'
3
4
  import minilog from 'cozy-minilog'
4
5
  import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'
5
6
  import { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'
@@ -7,52 +8,83 @@ import DropdownButton from 'cozy-ui/transpiled/react/DropdownButton'
7
8
  import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
8
9
  import { useI18n } from 'twake-i18n'
9
10
 
11
+ import DowngradePermissionConfirmDialog from './DowngradePermissionConfirmDialog'
10
12
  import { setReadOnlySharedPermission } from './actions/setReadOnlySharedPermission'
11
13
  import { setReadWriteSharedPermission } from './actions/setReadWriteSharedPermission'
12
14
  import withLocales from '../../hoc/withLocales'
15
+ import { useFetchDocumentPath } from '../../hooks/useFetchDocumentPath'
13
16
  import { useSharingContext } from '../../hooks/useSharingContext'
14
17
 
15
18
  const log = minilog('PermissionTypeMenu')
16
19
 
17
- const PermissionTypeMenuComponent = ({ sharingId, memberIndex, type }) => {
20
+ const PermissionTypeMenuComponent = ({
21
+ sharingId,
22
+ memberIndex,
23
+ type,
24
+ document,
25
+ recipient
26
+ }) => {
18
27
  const { t } = useI18n()
28
+ const client = useClient()
19
29
  const buttonRef = useRef()
20
- const { updateSharingMemberType } = useSharingContext()
30
+ const { hasSharedParent, updateSharingMemberType } = useSharingContext()
21
31
  const { showAlert } = useAlert()
22
32
 
23
33
  const [isMenuDisplayed, setMenuDisplayed] = useState(false)
34
+ const [pendingType, setPendingType] = useState(null)
35
+
36
+ const documentPath = useFetchDocumentPath(client, document)
24
37
 
25
38
  const hideMenu = useCallback(() => {
26
39
  setMenuDisplayed(false)
27
40
  }, [])
28
41
 
29
- const setType = useCallback(
42
+ const applyType = useCallback(
30
43
  async newType => {
44
+ try {
45
+ await updateSharingMemberType(sharingId, memberIndex, newType)
46
+ } catch (error) {
47
+ log.error('Failed to change member permission type', error)
48
+ showAlert({
49
+ message: t('Share.members.error.changePermission'),
50
+ severity: 'error',
51
+ variant: 'filled'
52
+ })
53
+ }
54
+ },
55
+ [memberIndex, sharingId, showAlert, t, updateSharingMemberType]
56
+ )
57
+
58
+ // Downgrading to viewer on a folder inside a shared parent also reduces
59
+ // the member's rights on the parent: require explicit confirmation first.
60
+ const shouldConfirmDowngrade =
61
+ type === 'two-way' &&
62
+ Boolean(documentPath) &&
63
+ hasSharedParent?.(documentPath)
64
+
65
+ const setType = useCallback(
66
+ newType => {
31
67
  hideMenu()
32
- if (newType !== type) {
33
- try {
34
- await updateSharingMemberType(sharingId, memberIndex, newType)
35
- } catch (error) {
36
- log.error('Failed to change member permission type', error)
37
- showAlert({
38
- message: t('Share.members.error.changePermission'),
39
- severity: 'error',
40
- variant: 'filled'
41
- })
42
- }
68
+ if (newType === type) return
69
+ if (newType === 'one-way' && shouldConfirmDowngrade) {
70
+ setPendingType(newType)
71
+ return
43
72
  }
73
+ applyType(newType)
44
74
  },
45
- [
46
- hideMenu,
47
- memberIndex,
48
- sharingId,
49
- showAlert,
50
- t,
51
- type,
52
- updateSharingMemberType
53
- ]
75
+ [applyType, hideMenu, shouldConfirmDowngrade, type]
54
76
  )
55
77
 
78
+ const handleConfirmDowngrade = useCallback(() => {
79
+ const newType = pendingType
80
+ setPendingType(null)
81
+ if (newType) applyType(newType)
82
+ }, [applyType, pendingType])
83
+
84
+ const handleCancelDowngrade = useCallback(() => {
85
+ setPendingType(null)
86
+ }, [])
87
+
56
88
  const actions = makeActions(
57
89
  [setReadOnlySharedPermission, setReadWriteSharedPermission],
58
90
  {
@@ -80,6 +112,14 @@ const PermissionTypeMenuComponent = ({ sharingId, memberIndex, type }) => {
80
112
  autoClose
81
113
  onClose={hideMenu}
82
114
  />
115
+ {pendingType && (
116
+ <DowngradePermissionConfirmDialog
117
+ document={document}
118
+ recipient={recipient}
119
+ onCancel={handleCancelDowngrade}
120
+ onConfirm={handleConfirmDowngrade}
121
+ />
122
+ )}
83
123
  </>
84
124
  )
85
125
  }
@@ -7,10 +7,14 @@ import { PermissionTypeMenu } from './PermissionTypeMenu'
7
7
  import AppLike from '../../../test/AppLike'
8
8
 
9
9
  const mockUpdateSharingMemberType = jest.fn()
10
+ const mockHasSharedParent = jest.fn()
11
+ const mockGetSharedParentPath = jest.fn()
10
12
  const mockShowAlert = jest.fn()
11
13
 
12
14
  jest.mock('../../hooks/useSharingContext', () => ({
13
15
  useSharingContext: () => ({
16
+ hasSharedParent: mockHasSharedParent,
17
+ getSharedParentPath: mockGetSharedParentPath,
14
18
  updateSharingMemberType: mockUpdateSharingMemberType
15
19
  })
16
20
  }))
@@ -32,7 +36,8 @@ describe('PermissionTypeMenu component', () => {
32
36
  const defaultProps = {
33
37
  sharingId: 'sharing-123',
34
38
  memberIndex: 1,
35
- type: 'two-way'
39
+ type: 'two-way',
40
+ recipient: { name: 'Alice' }
36
41
  }
37
42
 
38
43
  beforeEach(() => {
@@ -128,4 +133,83 @@ describe('PermissionTypeMenu component', () => {
128
133
 
129
134
  expect(mockUpdateSharingMemberType).not.toHaveBeenCalled()
130
135
  })
136
+
137
+ describe('when downgrading to viewer on a folder with a shared parent', () => {
138
+ const folderDocument = {
139
+ _id: 'doc-1',
140
+ type: 'directory',
141
+ path: '/parent/child'
142
+ }
143
+
144
+ beforeEach(() => {
145
+ mockHasSharedParent.mockReturnValue(true)
146
+ })
147
+
148
+ it('should ask for confirmation instead of updating directly', () => {
149
+ const { getByRole } = setup({
150
+ type: 'two-way',
151
+ document: folderDocument
152
+ })
153
+
154
+ fireEvent.click(getByRole('button', { name: 'Editor' }))
155
+ fireEvent.click(getByRole('menuitem', { name: 'Viewer' }))
156
+
157
+ expect(mockUpdateSharingMemberType).not.toHaveBeenCalled()
158
+ expect(getByRole('button', { name: 'Update parent' })).toBeInTheDocument()
159
+ })
160
+
161
+ it('should call updateSharingMemberType when confirming', async () => {
162
+ mockUpdateSharingMemberType.mockResolvedValue(undefined)
163
+
164
+ const { getByRole } = setup({
165
+ type: 'two-way',
166
+ document: folderDocument
167
+ })
168
+
169
+ fireEvent.click(getByRole('button', { name: 'Editor' }))
170
+ fireEvent.click(getByRole('menuitem', { name: 'Viewer' }))
171
+ fireEvent.click(getByRole('button', { name: 'Update parent' }))
172
+
173
+ await waitFor(() => {
174
+ expect(mockUpdateSharingMemberType).toHaveBeenCalledWith(
175
+ 'sharing-123',
176
+ 1,
177
+ 'one-way'
178
+ )
179
+ })
180
+ })
181
+
182
+ it('should not call updateSharingMemberType when cancelling', () => {
183
+ const { getByRole, queryByRole } = setup({
184
+ type: 'two-way',
185
+ document: folderDocument
186
+ })
187
+
188
+ fireEvent.click(getByRole('button', { name: 'Editor' }))
189
+ fireEvent.click(getByRole('menuitem', { name: 'Viewer' }))
190
+ fireEvent.click(getByRole('button', { name: 'Cancel' }))
191
+
192
+ expect(mockUpdateSharingMemberType).not.toHaveBeenCalled()
193
+ expect(queryByRole('button', { name: 'Update parent' })).toBe(null)
194
+ })
195
+
196
+ it('should update directly when the folder has no shared parent', () => {
197
+ mockHasSharedParent.mockReturnValue(false)
198
+
199
+ const { getByRole, queryByRole } = setup({
200
+ type: 'two-way',
201
+ document: folderDocument
202
+ })
203
+
204
+ fireEvent.click(getByRole('button', { name: 'Editor' }))
205
+ fireEvent.click(getByRole('menuitem', { name: 'Viewer' }))
206
+
207
+ expect(mockUpdateSharingMemberType).toHaveBeenCalledWith(
208
+ 'sharing-123',
209
+ 1,
210
+ 'one-way'
211
+ )
212
+ expect(queryByRole('button', { name: 'Update parent' })).toBe(null)
213
+ })
214
+ })
131
215
  })
@@ -2,6 +2,8 @@ import PropTypes from 'prop-types'
2
2
  import React from 'react'
3
3
  import ReactTooltip from 'react-tooltip'
4
4
 
5
+ import { useI18n } from 'twake-i18n'
6
+
5
7
  import styles from '../styles/tooltip.styl'
6
8
 
7
9
  export const SharingTooltip = props => (
@@ -16,29 +18,23 @@ export const SharingTooltip = props => (
16
18
  )
17
19
  // accepts all the props from https://github.com/wwayne/react-tooltip#options
18
20
 
19
- export class TooltipRecipientList extends React.Component {
20
- static contextTypes = {
21
- t: PropTypes.func.isRequired
22
- }
23
- render() {
24
- const { t } = this.context
25
- const { recipientNames, cutoff = 4 } = this.props
21
+ export const TooltipRecipientList = ({ recipientNames, cutoff }) => {
22
+ const { t } = useI18n()
26
23
 
27
- return (
28
- <ul className={styles['shared-tooltip-list']}>
29
- {recipientNames.slice(0, cutoff).map((name, i) => (
30
- <li key={`key_name_${name}_${i}`}>{name}</li>
31
- ))}
32
- {recipientNames.length > cutoff && (
33
- <li>
34
- {t('Share.members.others', {
35
- smart_count: recipientNames.slice(cutoff).length
36
- })}
37
- </li>
38
- )}
39
- </ul>
40
- )
41
- }
24
+ return (
25
+ <ul className={styles['shared-tooltip-list']}>
26
+ {recipientNames.slice(0, cutoff).map((name, i) => (
27
+ <li key={`key_name_${name}_${i}`}>{name}</li>
28
+ ))}
29
+ {recipientNames.length > cutoff && (
30
+ <li>
31
+ {t('Share.members.others', {
32
+ smart_count: recipientNames.slice(cutoff).length
33
+ })}
34
+ </li>
35
+ )}
36
+ </ul>
37
+ )
42
38
  }
43
39
 
44
40
  TooltipRecipientList.propTypes = {
@@ -4,11 +4,13 @@ import { fetchFilesPaths } from '../helpers/files'
4
4
 
5
5
  export const useFetchDocumentPath = (client, document) => {
6
6
  const [documentPath, setDocumentPath] = useState(
7
- document.path ? document.path : null
7
+ document?.path ? document.path : null
8
8
  )
9
9
  useEffect(() => {
10
10
  ;(async () => {
11
11
  try {
12
+ if (!document) return setDocumentPath(null)
13
+
12
14
  const isDirectory = document.type === 'directory'
13
15
 
14
16
  if (isDirectory) return setDocumentPath(document.path)
package/src/state.js CHANGED
@@ -570,7 +570,7 @@ export const hasSharedChild = (state, documentPath) => {
570
570
  */
571
571
  export const getSharedParentPath = (state, documentPath) => {
572
572
  if (hasSharedParent(state, documentPath)) {
573
- return state.sharedPaths.find(path => documentPath.startsWith(path))
573
+ return state.sharedPaths.find(path => documentPath.startsWith(`${path}/`))
574
574
  }
575
575
  return null
576
576
  }