@plone-collective/volto-identity 1.0.0-alpha.1

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.
Files changed (191) hide show
  1. package/.changelog.draft +261 -0
  2. package/.release-it.json +34 -0
  3. package/CHANGELOG.md +269 -0
  4. package/LICENSE +21 -0
  5. package/README.md +228 -0
  6. package/babel.config.js +17 -0
  7. package/locales/de/LC_MESSAGES/volto.po +1162 -0
  8. package/locales/en/LC_MESSAGES/volto.po +1162 -0
  9. package/locales/es/LC_MESSAGES/volto.po +1169 -0
  10. package/locales/pt_BR/LC_MESSAGES/volto.po +1167 -0
  11. package/locales/volto.pot +1164 -0
  12. package/news/.changelog_template.jinja +15 -0
  13. package/news/.gitkeep +0 -0
  14. package/package.json +66 -0
  15. package/public/.gitkeep +0 -0
  16. package/src/actions/actions.test.ts +226 -0
  17. package/src/actions/index.ts +530 -0
  18. package/src/components/.gitkeep +0 -0
  19. package/src/components/Applications/Applications.tsx +169 -0
  20. package/src/components/Applications/ApplicationsPanel.scss +59 -0
  21. package/src/components/Applications/ApplicationsPanel.stories.tsx +82 -0
  22. package/src/components/Applications/ApplicationsPanel.test.tsx +235 -0
  23. package/src/components/Applications/ApplicationsPanel.tsx +342 -0
  24. package/src/components/Callback/Callback.scss +17 -0
  25. package/src/components/Callback/Callback.stories.tsx +43 -0
  26. package/src/components/Callback/Callback.test.tsx +262 -0
  27. package/src/components/Callback/Callback.tsx +170 -0
  28. package/src/components/Consent/Consent.test.tsx +75 -0
  29. package/src/components/Consent/Consent.tsx +98 -0
  30. package/src/components/Consent/ConsentPanel.scss +37 -0
  31. package/src/components/Consent/ConsentPanel.stories.tsx +80 -0
  32. package/src/components/Consent/ConsentPanel.test.tsx +138 -0
  33. package/src/components/Consent/ConsentPanel.tsx +162 -0
  34. package/src/components/ControlPanel/ClientsControlPanel.stories.tsx +38 -0
  35. package/src/components/ControlPanel/ClientsControlPanel.tsx +321 -0
  36. package/src/components/ControlPanel/ClientsPanel.scss +21 -0
  37. package/src/components/ControlPanel/ClientsPanel.stories.tsx +71 -0
  38. package/src/components/ControlPanel/ClientsPanel.test.tsx +267 -0
  39. package/src/components/ControlPanel/ClientsPanel.tsx +386 -0
  40. package/src/components/ControlPanel/ConfirmModal.stories.tsx +35 -0
  41. package/src/components/ControlPanel/ConfirmModal.test.tsx +66 -0
  42. package/src/components/ControlPanel/ConfirmModal.tsx +68 -0
  43. package/src/components/ControlPanel/ProviderForm.stories.tsx +107 -0
  44. package/src/components/ControlPanel/ProvidersControlPanel.scss +26 -0
  45. package/src/components/ControlPanel/ProvidersControlPanel.stories.tsx +88 -0
  46. package/src/components/ControlPanel/ProvidersControlPanel.tsx +600 -0
  47. package/src/components/ControlPanel/RenderUsers.stories.tsx +112 -0
  48. package/src/components/ControlPanel/RenderUsers.test.tsx +145 -0
  49. package/src/components/ControlPanel/RenderUsers.tsx +263 -0
  50. package/src/components/ControlPanel/SecretReveal.scss +32 -0
  51. package/src/components/ControlPanel/SecretReveal.stories.tsx +33 -0
  52. package/src/components/ControlPanel/SecretReveal.test.tsx +101 -0
  53. package/src/components/ControlPanel/SecretReveal.tsx +105 -0
  54. package/src/components/ControlPanel/UserAccount.stories.tsx +69 -0
  55. package/src/components/ControlPanel/UserAccount.test.tsx +117 -0
  56. package/src/components/ControlPanel/UserAccount.tsx +125 -0
  57. package/src/components/ControlPanel/UserAccountPanel.scss +38 -0
  58. package/src/components/ControlPanel/UserAccountPanel.stories.tsx +77 -0
  59. package/src/components/ControlPanel/UserAccountPanel.test.tsx +173 -0
  60. package/src/components/ControlPanel/UserAccountPanel.tsx +273 -0
  61. package/src/components/FirstLogin/FirstLogin.scss +12 -0
  62. package/src/components/FirstLogin/FirstLogin.stories.tsx +50 -0
  63. package/src/components/FirstLogin/FirstLogin.tsx +94 -0
  64. package/src/components/Identities/Identities.stories.tsx +51 -0
  65. package/src/components/Identities/Identities.test.tsx +134 -0
  66. package/src/components/Identities/Identities.tsx +217 -0
  67. package/src/components/Identities/IdentitiesList.scss +49 -0
  68. package/src/components/Identities/IdentitiesList.stories.tsx +71 -0
  69. package/src/components/Identities/IdentitiesList.test.tsx +218 -0
  70. package/src/components/Identities/IdentitiesList.tsx +207 -0
  71. package/src/components/Identities/ProfileEmails.scss +62 -0
  72. package/src/components/Identities/ProfileEmails.stories.tsx +75 -0
  73. package/src/components/Identities/ProfileEmails.test.tsx +132 -0
  74. package/src/components/Identities/ProfileEmails.tsx +217 -0
  75. package/src/components/Login/Login.stories.tsx +63 -0
  76. package/src/components/Login/Login.test.tsx +189 -0
  77. package/src/components/Login/Login.tsx +163 -0
  78. package/src/components/Login/LoginForm.scss +39 -0
  79. package/src/components/Login/LoginForm.stories.tsx +112 -0
  80. package/src/components/Login/LoginForm.test.tsx +483 -0
  81. package/src/components/Login/LoginForm.tsx +321 -0
  82. package/src/components/Login/LoginOverlay.scss +44 -0
  83. package/src/components/Login/LoginOverlay.stories.tsx +73 -0
  84. package/src/components/Login/LoginOverlay.test.tsx +98 -0
  85. package/src/components/Login/LoginOverlay.tsx +107 -0
  86. package/src/components/Login/LoginPanel.scss +50 -0
  87. package/src/components/Login/LoginPanel.stories.tsx +40 -0
  88. package/src/components/Login/LoginPanel.tsx +60 -0
  89. package/src/components/Login/MagicLinkForm.scss +25 -0
  90. package/src/components/Login/MagicLinkForm.stories.tsx +27 -0
  91. package/src/components/Login/MagicLinkForm.tsx +133 -0
  92. package/src/components/Login/PasswordForm.scss +82 -0
  93. package/src/components/Login/PasswordForm.stories.tsx +26 -0
  94. package/src/components/Login/PasswordForm.tsx +159 -0
  95. package/src/components/Login/ProviderButton.scss +103 -0
  96. package/src/components/Login/ProviderButton.stories.tsx +74 -0
  97. package/src/components/Login/ProviderButton.test.tsx +99 -0
  98. package/src/components/Login/ProviderButton.tsx +97 -0
  99. package/src/components/ProfileGate/ProfileGate.stories.tsx +118 -0
  100. package/src/components/ProfileGate/ProfileGate.test.tsx +370 -0
  101. package/src/components/ProfileGate/ProfileGate.tsx +172 -0
  102. package/src/components/Toolbar/PersonalTools.stories.tsx +111 -0
  103. package/src/components/Toolbar/PersonalTools.test.tsx +165 -0
  104. package/src/components/Toolbar/PersonalTools.tsx +177 -0
  105. package/src/components/Toolbar/Toolbar.jsx +766 -0
  106. package/src/components/UserMenu/ApplicationsMenuItem.stories.tsx +62 -0
  107. package/src/components/UserMenu/ApplicationsMenuItem.test.tsx +109 -0
  108. package/src/components/UserMenu/ApplicationsMenuItem.tsx +82 -0
  109. package/src/components/UserMenu/IdentitiesMenuItem.stories.tsx +35 -0
  110. package/src/components/UserMenu/IdentitiesMenuItem.test.tsx +103 -0
  111. package/src/components/UserMenu/IdentitiesMenuItem.tsx +34 -0
  112. package/src/components/UserMenu/MenuItem.stories.tsx +32 -0
  113. package/src/components/UserMenu/MenuItem.tsx +48 -0
  114. package/src/components/UserMenu/ProfileMenuItem.stories.tsx +76 -0
  115. package/src/components/UserMenu/ProfileMenuItem.test.tsx +90 -0
  116. package/src/components/UserMenu/ProfileMenuItem.tsx +60 -0
  117. package/src/components/UserMenu/UserAvatar.scss +23 -0
  118. package/src/components/UserMenu/UserAvatar.stories.tsx +121 -0
  119. package/src/components/UserMenu/UserAvatar.test.tsx +120 -0
  120. package/src/components/UserMenu/UserAvatar.tsx +119 -0
  121. package/src/components/UserMenu/UserMenuPlugs.stories.tsx +101 -0
  122. package/src/components/UserMenu/UserMenuPlugs.test.tsx +202 -0
  123. package/src/components/UserMenu/UserMenuPlugs.tsx +142 -0
  124. package/src/components/UserMenu/UserProfileLoader.test.tsx +211 -0
  125. package/src/components/UserMenu/UserProfileLoader.tsx +54 -0
  126. package/src/components/Views/GroupView.scss +36 -0
  127. package/src/components/Views/GroupView.stories.tsx +98 -0
  128. package/src/components/Views/GroupView.test.tsx +137 -0
  129. package/src/components/Views/GroupView.tsx +178 -0
  130. package/src/components/Views/ProfileView.scss +27 -0
  131. package/src/components/Views/ProfileView.stories.tsx +34 -0
  132. package/src/components/Views/ProfileView.test.tsx +82 -0
  133. package/src/components/Views/ProfileView.tsx +100 -0
  134. package/src/components/Widgets/ProviderIconWidget.scss +21 -0
  135. package/src/components/Widgets/ProviderIconWidget.stories.tsx +30 -0
  136. package/src/components/Widgets/ProviderIconWidget.test.tsx +55 -0
  137. package/src/components/Widgets/ProviderIconWidget.tsx +143 -0
  138. package/src/config/menu.ts +35 -0
  139. package/src/config/reducers.ts +7 -0
  140. package/src/config/routes.test.ts +177 -0
  141. package/src/config/routes.ts +143 -0
  142. package/src/config/settings.test.ts +34 -0
  143. package/src/config/settings.ts +72 -0
  144. package/src/config/views.ts +30 -0
  145. package/src/config/widgets.ts +25 -0
  146. package/src/constants/ActionTypes.ts +42 -0
  147. package/src/constants/vocabularies.ts +20 -0
  148. package/src/customizations/volto/components/manage/Controlpanels/Users/RenderUsers.tsx +23 -0
  149. package/src/customizations/volto/components/manage/Toolbar/PersonalTools.tsx +21 -0
  150. package/src/customizations/volto/components/manage/Toolbar/Toolbar.jsx +21 -0
  151. package/src/helpers/avatar.test.ts +79 -0
  152. package/src/helpers/avatar.ts +85 -0
  153. package/src/helpers/callback.test.ts +60 -0
  154. package/src/helpers/callback.ts +71 -0
  155. package/src/helpers/clientSchema.test.ts +182 -0
  156. package/src/helpers/clientSchema.ts +181 -0
  157. package/src/helpers/firstLogin.test.ts +90 -0
  158. package/src/helpers/firstLogin.ts +76 -0
  159. package/src/helpers/groupmap.test.ts +81 -0
  160. package/src/helpers/groupmap.ts +50 -0
  161. package/src/helpers/identities.ts +22 -0
  162. package/src/helpers/navigate.test.ts +132 -0
  163. package/src/helpers/navigate.ts +126 -0
  164. package/src/helpers/profileGate.test.ts +150 -0
  165. package/src/helpers/profileGate.ts +191 -0
  166. package/src/helpers/profileSource.ts +41 -0
  167. package/src/helpers/propertymap.test.ts +71 -0
  168. package/src/helpers/propertymap.ts +43 -0
  169. package/src/helpers/providerSchema.test.ts +410 -0
  170. package/src/helpers/providerSchema.ts +362 -0
  171. package/src/helpers/returnUrl.test.ts +42 -0
  172. package/src/helpers/returnUrl.ts +41 -0
  173. package/src/helpers/rowmap.ts +110 -0
  174. package/src/helpers/showPloneLogin.test.ts +78 -0
  175. package/src/helpers/showPloneLogin.ts +55 -0
  176. package/src/helpers/token.test.ts +62 -0
  177. package/src/helpers/token.ts +71 -0
  178. package/src/index.ts +22 -0
  179. package/src/reducers/index.ts +369 -0
  180. package/src/reducers/reducers.test.ts +369 -0
  181. package/src/stories/fixtures.tsx +640 -0
  182. package/src/storybook/withPage.test.tsx +90 -0
  183. package/src/storybook/withPage.tsx +110 -0
  184. package/src/storybook/withUserMenu.test.tsx +78 -0
  185. package/src/storybook/withUserMenu.tsx +136 -0
  186. package/src/styles.css +262 -0
  187. package/src/testing.tsx +86 -0
  188. package/src/types.ts +441 -0
  189. package/towncrier.toml +38 -0
  190. package/tsconfig.json +22 -0
  191. package/vitest.config.mjs +47 -0
@@ -0,0 +1,112 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import { Provider } from 'react-redux';
4
+ import React from 'react';
5
+ import { Table } from 'semantic-ui-react';
6
+
7
+ import RenderUsers from './RenderUsers';
8
+
9
+ const ROLES = [{ id: 'Member' }, { id: 'Reviewer' }, { id: 'Manager' }];
10
+
11
+ const STATE = { users: { update: { loading: false } } };
12
+
13
+ const STORE = {
14
+ getState: () => STATE,
15
+ dispatch: (action: unknown) => action,
16
+ subscribe: () => () => {},
17
+ } as never;
18
+
19
+ const USERSCHEMA = {
20
+ loaded: true,
21
+ userschema: { properties: {}, fieldsets: [] },
22
+ };
23
+
24
+ const ALICE = {
25
+ '@id': 'http://localhost:8080/Plone/@users/alice',
26
+ id: 'alice',
27
+ username: 'alice',
28
+ fullname: 'Alice Liddell',
29
+ email: 'alice@example.com',
30
+ roles: ['Member'],
31
+ source: 'source_users',
32
+ identities: [],
33
+ profile_url: null,
34
+ };
35
+
36
+ /**
37
+ * One row of {menuselection}`Site Setup --> Users`. The whole point of the
38
+ * row is in the menu behind the ellipsis, so open it to see anything: Edit
39
+ * leads to the Profile for a user who has one, and to Volto's
40
+ * member-properties modal for a user who does not.
41
+ */
42
+ const meta: Meta<typeof RenderUsers> = {
43
+ title: 'Identity/Controlpanels/RenderUsers',
44
+ component: RenderUsers,
45
+ decorators: [
46
+ (Story) => (
47
+ <Provider store={STORE}>
48
+ <MemoryRouter>
49
+ <Table compact>
50
+ <Table.Header>
51
+ <Table.Row>
52
+ <Table.HeaderCell>User name</Table.HeaderCell>
53
+ {ROLES.map((role) => (
54
+ <Table.HeaderCell key={role.id}>{role.id}</Table.HeaderCell>
55
+ ))}
56
+ <Table.HeaderCell />
57
+ </Table.Row>
58
+ </Table.Header>
59
+ <Table.Body>
60
+ <Story />
61
+ </Table.Body>
62
+ </Table>
63
+ </MemoryRouter>
64
+ </Provider>
65
+ ),
66
+ ],
67
+ args: {
68
+ user: ALICE as never,
69
+ roles: ROLES,
70
+ isUserManager: true,
71
+ updateUser: () => {},
72
+ onDelete: () => {},
73
+ userschema: USERSCHEMA,
74
+ },
75
+ };
76
+ export default meta;
77
+
78
+ type Story = StoryObj<typeof RenderUsers>;
79
+
80
+ /**
81
+ * Their fields live in a Profile, so Edit is a link to that Profile's own
82
+ * edit form — where their actual fields are, rather than the subset
83
+ * `@userschema` names in a store nothing reads for them.
84
+ */
85
+ export const BackedByAProfile: Story = {
86
+ args: {
87
+ user: {
88
+ ...ALICE,
89
+ profile_url: 'http://localhost:8080/Plone/profiles/alice',
90
+ } as never,
91
+ },
92
+ };
93
+
94
+ /**
95
+ * The site's own `admin`, an account made before the extra was installed, or
96
+ * any user on a site not running it. Volto's modal, exactly as before.
97
+ */
98
+ export const NotBackedByAProfile: Story = {};
99
+
100
+ /**
101
+ * Roles held through a group are shown as Plone's own icon rather than a
102
+ * checkbox: they are not this row's to change.
103
+ */
104
+ export const WithAnInheritedRole: Story = {
105
+ args: {
106
+ user: {
107
+ ...ALICE,
108
+ profile_url: 'http://localhost:8080/Plone/profiles/alice',
109
+ } as never,
110
+ inheritedRole: ['Reviewer'],
111
+ },
112
+ };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The shadowed users-control-panel row.
3
+ *
4
+ * What is pinned here is the one thing that differs from Volto: where Edit
5
+ * leads. A user whose fields live in a Profile is edited on that Profile; a
6
+ * user whose fields live in `portal_memberdata` still gets Volto's modal.
7
+ * Both halves matter — the second is the site's own `admin`, and any account
8
+ * that predates the add-on and has not signed in since.
9
+ *
10
+ * The Account action is pinned here for the same reason: it is a link to a
11
+ * route rather than something that opens an overlay in this row, and nothing
12
+ * else would notice it going back to being a modal.
13
+ */
14
+ import { describe, expect, it, vi } from 'vitest';
15
+ import { fireEvent, render, screen } from '../../testing';
16
+ import { MemoryRouter } from 'react-router-dom';
17
+ import { Provider } from 'react-redux';
18
+ import React from 'react';
19
+ import { Table } from 'semantic-ui-react';
20
+
21
+ import RenderUsers, { profileEditUrl } from './RenderUsers';
22
+ import { userAccountUrl } from '../../config/routes';
23
+
24
+ const ROLES = [{ id: 'Member' }, { id: 'Manager' }];
25
+
26
+ const USERSCHEMA = {
27
+ loaded: true,
28
+ userschema: { properties: {}, fieldsets: [] },
29
+ };
30
+
31
+ function renderRow(user: Record<string, unknown>) {
32
+ // One state object, not a fresh literal per call: the component selects
33
+ // `users.update` out of it, and `useSelector` compares by identity — a new
34
+ // object every `getState` is an infinite re-render.
35
+ const state = { users: { update: { loading: false } } };
36
+ const store = {
37
+ getState: () => state,
38
+ dispatch: vi.fn(),
39
+ subscribe: () => () => {},
40
+ };
41
+ return render(
42
+ <Provider store={store as never}>
43
+ <MemoryRouter>
44
+ <Table>
45
+ <Table.Body>
46
+ <RenderUsers
47
+ user={
48
+ {
49
+ '@id': 'http://localhost:8080/Plone/@users/alice',
50
+ id: 'alice',
51
+ username: 'alice',
52
+ fullname: 'Alice Liddell',
53
+ roles: [],
54
+ source: 'source_users',
55
+ identities: [],
56
+ profile_url: null,
57
+ ...user,
58
+ } as never
59
+ }
60
+ roles={ROLES}
61
+ isUserManager={true}
62
+ updateUser={vi.fn()}
63
+ onDelete={vi.fn()}
64
+ userschema={USERSCHEMA}
65
+ />
66
+ </Table.Body>
67
+ </Table>
68
+ </MemoryRouter>
69
+ </Provider>,
70
+ );
71
+ }
72
+
73
+ describe('profileEditUrl', () => {
74
+ it('is the Profile edit form, app-relative', () => {
75
+ expect(profileEditUrl('http://localhost:8080/Plone/profiles/alice')).toBe(
76
+ '/profiles/alice/edit',
77
+ );
78
+ });
79
+
80
+ it('is nothing for a user with no Profile', () => {
81
+ // Which is what leaves Volto's member-properties modal in place for
82
+ // them: the site's own admin, and anyone else with no Profile.
83
+ expect(profileEditUrl(null)).toBeNull();
84
+ expect(profileEditUrl(undefined)).toBeNull();
85
+ expect(profileEditUrl('')).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe('RenderUsers', () => {
90
+ it('links Edit to the Profile when the user has one', () => {
91
+ // The defect this fixes: the modal edits `portal_memberdata`, which is
92
+ // not where this user's fields are read from, and shows only the fields
93
+ // `@userschema` happens to name.
94
+ renderRow({ profile_url: 'http://localhost:8080/Plone/profiles/alice' });
95
+
96
+ const edit = document.querySelector('#edit-user-button');
97
+
98
+ expect(edit?.tagName).toBe('A');
99
+ expect(edit?.getAttribute('href')).toBe('/profiles/alice/edit');
100
+ });
101
+
102
+ it('leaves the modal in place for a user without a Profile', () => {
103
+ renderRow({ profile_url: null });
104
+
105
+ const edit = document.querySelector('#edit-user-button');
106
+
107
+ expect(edit).not.toBeNull();
108
+ expect(edit?.tagName).not.toBe('A');
109
+ });
110
+
111
+ it('still offers Delete either way', () => {
112
+ // The row keeps working: the change is the destination of one entry, not
113
+ // the menu it is in.
114
+ renderRow({ profile_url: 'http://localhost:8080/Plone/profiles/alice' });
115
+
116
+ expect(document.querySelector('#delete-user-button')).not.toBeNull();
117
+ });
118
+
119
+ it('shows the user the way the panel always has', () => {
120
+ renderRow({});
121
+
122
+ expect(screen.getByText(/Alice Liddell/)).toBeTruthy();
123
+ });
124
+ });
125
+
126
+ describe('the Account action', () => {
127
+ it('is a link to the account page', () => {
128
+ // A navigation, so it is an anchor: middle-click, open-in-new-tab and a
129
+ // visible target in the status bar all work, and none of them do for a
130
+ // menu item with an onClick that opens a modal.
131
+ renderRow({ profile_url: null });
132
+
133
+ const item = document.querySelector('#user-account-button');
134
+ expect(item?.tagName).toBe('A');
135
+ expect(item?.getAttribute('href')).toBe(userAccountUrl('alice'));
136
+ });
137
+
138
+ it('opens nothing in the row itself', () => {
139
+ renderRow({ profile_url: null });
140
+
141
+ fireEvent.click(screen.getByText('Account'));
142
+
143
+ expect(document.querySelector('.ui.modal')).toBeNull();
144
+ });
145
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * One row of the users control panel, rewritten.
3
+ *
4
+ * Takes Volto's place through
5
+ * `customizations/volto/components/manage/Controlpanels/Users/RenderUsers`,
6
+ * which is where the reason for shadowing it is written down.
7
+ *
8
+ * Not a verbatim copy of `@plone/volto/components/manage/Controlpanels/Users/RenderUsers`
9
+ * (Volto 19.3.0) with a patch: it keeps the markup, the class names, the
10
+ * element ids and the behaviour Volto's stylesheet and tests expect, and
11
+ * changes the one thing worth changing. On a Volto upgrade, read upstream's
12
+ * diff for *behavioural* changes and port those.
13
+ *
14
+ * WHAT DIFFERS FROM UPSTREAM, and why:
15
+ *
16
+ * - **Edit goes to the Profile, for a user who has one.** Upstream always
17
+ * opens a modal bound to `@userschema` — the member-properties form, which
18
+ * writes through `portal_memberdata`. That form is the *wrong* form for
19
+ * anyone whose fields live in a Profile: it shows the subset of fields the
20
+ * schema happens to name,
21
+ * nothing that type added, and it edits a store which is not where that
22
+ * user's data is read from. Their Profile has an edit form of its own, with
23
+ * their actual fields on it, so Edit links there.
24
+ *
25
+ * - **Everyone else is untouched.** The site's `admin`, an account created
26
+ * before the extra was installed, any user on a site without it: no
27
+ * `profile_url`, so the modal opens exactly as before. That is the same
28
+ * condition the user menu keys on — see `helpers/profileSource` — and it is
29
+ * `profile_url` rather than `source` for the reason written down there:
30
+ * `source` names the plugin that *authenticated* the account, which is
31
+ * `source_users` for everybody, so keying on it hides the feature from
32
+ * every user it is for.
33
+ *
34
+ * - **The link is a `Link`, not a click handler.** It is a navigation, so it
35
+ * is an anchor: middle-click, open-in-new-tab and a visible target in the
36
+ * status bar all work, and none of them do for a `div` with an `onClick`.
37
+ *
38
+ * - **An Account action.** Which providers a person has configured and when
39
+ * they last signed in are the two questions this control panel could not
40
+ * answer -- the first because `@users` carries bare provider ids, the
41
+ * second because nothing in Plone records it. `@user-account/<id>` answers
42
+ * both, one user at a time, and this is the action that opens it.
43
+ *
44
+ * A `Link` to a route rather than a modal it fetched into itself, for the
45
+ * same reason Edit above is an anchor: it is a navigation to a page's worth
46
+ * of facts about one person, and an overlay could not be linked to,
47
+ * bookmarked or opened in a new tab. The request moved with it -- the page
48
+ * knows who it is about from the route.
49
+ *
50
+ * @module components/ControlPanel/RenderUsers
51
+ */
52
+ import React, { useState } from 'react';
53
+ import { FormattedMessage, useIntl } from 'react-intl';
54
+ import { useDispatch, useSelector } from 'react-redux';
55
+ import { Link } from 'react-router-dom';
56
+ import { Dropdown, Table, Checkbox } from 'semantic-ui-react';
57
+
58
+ import Icon from '@plone/volto/components/theme/Icon/Icon';
59
+ import Toast from '@plone/volto/components/manage/Toast/Toast';
60
+ import { ModalForm } from '@plone/volto/components/manage/Form';
61
+ import { updateUser } from '@plone/volto/actions/users/users';
62
+ import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
63
+ import { messages } from '@plone/volto/helpers/MessageLabels/MessageLabels';
64
+ import { canAssignRole } from '@plone/volto/helpers/User/User';
65
+ import editSVG from '@plone/volto/icons/editing.svg';
66
+ import ploneSVG from '@plone/volto/icons/plone.svg';
67
+ import trashSVG from '@plone/volto/icons/delete.svg';
68
+ import { toast } from 'react-toastify';
69
+
70
+ import type { UserProfile } from '../../types';
71
+ import { userAccountUrl } from '../../config/routes';
72
+
73
+ /** A role as the control panel lists them. */
74
+ interface Role {
75
+ id: string;
76
+ }
77
+
78
+ interface RenderUsersProps {
79
+ /** The user this row is about, as `@users` serializes them. */
80
+ user: UserProfile & { roles: string[] };
81
+ /** Every role the site has, in column order. */
82
+ roles: Role[];
83
+ onDelete: (event: React.MouseEvent, data: { value: string }) => void;
84
+ isUserManager: boolean;
85
+ listUsers?: () => void;
86
+ updateUser: (userId: string, role: string) => void;
87
+ /** Roles this user holds through a group rather than directly. */
88
+ inheritedRole?: string[];
89
+ userschema?: { loaded: boolean; userschema: object };
90
+ }
91
+
92
+ /**
93
+ * Where the Edit action should lead for one user.
94
+ *
95
+ * @param profileUrl The user's `profile_url`, when they have a Profile.
96
+ * @returns The app-relative edit URL, or `null` for a user whose fields are
97
+ * not in a Profile — for whom the member-properties modal is still right.
98
+ */
99
+ export function profileEditUrl(
100
+ profileUrl: string | null | undefined,
101
+ ): string | null {
102
+ if (!profileUrl) {
103
+ return null;
104
+ }
105
+ // The payload carries an absolute URL on the backend's own host; a router
106
+ // link needs the path this site serves it at.
107
+ return `${flattenToAppURL(profileUrl)}/edit`;
108
+ }
109
+
110
+ const RenderUsers: React.FC<RenderUsersProps> = (props) => {
111
+ const [user, setUser] = useState<Record<string, any>>({});
112
+
113
+ const intl = useIntl();
114
+ const dispatch = useDispatch();
115
+ const updateRequest = useSelector((state: any) => state.users?.update);
116
+
117
+ const {
118
+ user: propsUser,
119
+ listUsers,
120
+ updateUser: updateUserRole,
121
+ isUserManager,
122
+ roles,
123
+ inheritedRole,
124
+ userschema,
125
+ onDelete,
126
+ } = props;
127
+
128
+ const editUrl = profileEditUrl(propsUser.profile_url);
129
+
130
+ const updateUserData = (userId: string, userData: object) => {
131
+ (dispatch as any)(updateUser(userId, userData))
132
+ .then(() => {
133
+ setUser({});
134
+ if (listUsers) {
135
+ listUsers();
136
+ }
137
+ toast.success(
138
+ <Toast
139
+ success
140
+ title={intl.formatMessage(messages.success)}
141
+ content={intl.formatMessage(messages.updateUserSuccess)}
142
+ />,
143
+ );
144
+ })
145
+ .catch(() => {
146
+ toast.error(
147
+ <Toast
148
+ error
149
+ title={intl.formatMessage(messages.error)}
150
+ content={intl.formatMessage(messages.thereWereSomeErrors)}
151
+ />,
152
+ );
153
+ });
154
+ };
155
+
156
+ const onChange = (_event: unknown, { value }: { value: string }) => {
157
+ const [userId, role] = value.split('&role=');
158
+ updateUserRole(userId, role);
159
+ };
160
+
161
+ const onEditUserSubmit = (data: Record<string, any>) => {
162
+ // Roles and groups are edited in their own columns, not in this form.
163
+ delete data.groups;
164
+ delete data.roles;
165
+ updateUserData(data.id, data);
166
+ };
167
+
168
+ const canDeleteUser = () => {
169
+ if (isUserManager) return true;
170
+ return !propsUser.roles.includes('Manager');
171
+ };
172
+
173
+ return (
174
+ <Table.Row key={propsUser.username ?? propsUser.id}>
175
+ <Table.Cell className="fullname">
176
+ {propsUser.fullname ? propsUser.fullname : propsUser.username} (
177
+ {propsUser.username})
178
+ </Table.Cell>
179
+ {roles.map((role) => (
180
+ <Table.Cell key={role.id}>
181
+ {inheritedRole && inheritedRole.includes(role.id) ? (
182
+ <Icon
183
+ name={ploneSVG}
184
+ size="20px"
185
+ color="#007EB1"
186
+ title={'plone-svg'}
187
+ />
188
+ ) : (
189
+ <Checkbox
190
+ checked={propsUser.roles.includes(role.id)}
191
+ onChange={onChange as any}
192
+ value={`${propsUser.id}&role=${role.id}`}
193
+ disabled={!canAssignRole(isUserManager, role)}
194
+ />
195
+ )}
196
+ </Table.Cell>
197
+ ))}
198
+ <Table.Cell textAlign="right">
199
+ {canDeleteUser() && (
200
+ <Dropdown icon="ellipsis horizontal">
201
+ <Dropdown.Menu className="left">
202
+ {editUrl ? (
203
+ <Dropdown.Item
204
+ as={Link}
205
+ to={editUrl}
206
+ id="edit-user-button"
207
+ value={propsUser['@id']}
208
+ >
209
+ <Icon name={editSVG} size="15px" />
210
+ <FormattedMessage id="Edit" defaultMessage="Edit" />
211
+ </Dropdown.Item>
212
+ ) : (
213
+ userschema && (
214
+ <Dropdown.Item
215
+ id="edit-user-button"
216
+ onClick={() => {
217
+ setUser({ ...propsUser });
218
+ }}
219
+ value={propsUser['@id']}
220
+ >
221
+ <Icon name={editSVG} size="15px" />
222
+ <FormattedMessage id="Edit" defaultMessage="Edit" />
223
+ </Dropdown.Item>
224
+ )
225
+ )}
226
+ <Dropdown.Item
227
+ as={Link}
228
+ to={userAccountUrl(propsUser.id)}
229
+ id="user-account-button"
230
+ value={propsUser['@id']}
231
+ >
232
+ <Icon name={ploneSVG} size="15px" />
233
+ <FormattedMessage id="Account" defaultMessage="Account" />
234
+ </Dropdown.Item>
235
+ <Dropdown.Item
236
+ id="delete-user-button"
237
+ onClick={onDelete as any}
238
+ value={propsUser['@id']}
239
+ >
240
+ <Icon name={trashSVG} size="15px" />
241
+ <FormattedMessage id="Delete" defaultMessage="Delete" />
242
+ </Dropdown.Item>
243
+ </Dropdown.Menu>
244
+ </Dropdown>
245
+ )}
246
+ </Table.Cell>
247
+ {Object.keys(user).length > 0 && userschema?.loaded && (
248
+ <ModalForm
249
+ className="modal"
250
+ onSubmit={onEditUserSubmit}
251
+ submitError={user.editUserError}
252
+ formData={user}
253
+ onCancel={() => setUser({})}
254
+ title={intl.formatMessage(messages.updateUserFormTitle)}
255
+ loading={updateRequest.loading}
256
+ schema={userschema.userschema}
257
+ />
258
+ )}
259
+ </Table.Row>
260
+ );
261
+ };
262
+
263
+ export default RenderUsers;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The one moment a client secret exists.
3
+ *
4
+ * Deliberately unlike the rest of the page: this is the only panel in the
5
+ * add-on where dismissing something loses it irrecoverably.
6
+ */
7
+ .identity-secret {
8
+ padding: 1rem;
9
+ border: 2px solid var(--identity-danger);
10
+ border-radius: var(--identity-radius);
11
+ margin-bottom: 2rem;
12
+ }
13
+
14
+ .identity-secret__notice {
15
+ margin: 0 0 0.5rem;
16
+ font-weight: 500;
17
+ }
18
+
19
+ .identity-secret__value {
20
+ display: flex;
21
+ margin: 0.75rem 0;
22
+ gap: 0.5rem;
23
+
24
+ input {
25
+ flex: 1;
26
+ padding: 8px 6px;
27
+ border: 0;
28
+ border-bottom: 1px solid var(--identity-field-border);
29
+ background: transparent;
30
+ font-family: monospace;
31
+ }
32
+ }
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+
3
+ import SecretReveal from './SecretReveal';
4
+ import { CLIENT, MINTED_CLIENT } from '../../stories/fixtures';
5
+
6
+ const meta: Meta<typeof SecretReveal> = {
7
+ title: 'Identity/ControlPanel/SecretReveal',
8
+ component: SecretReveal,
9
+ args: { client: MINTED_CLIENT, onDismiss: () => {} },
10
+ };
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof SecretReveal>;
14
+
15
+ /**
16
+ * The one moment the secret exists in a form anybody can read. The server
17
+ * keeps a hash, so this is not recoverable and nearly the whole design of the
18
+ * clients panel is about this appearing exactly once.
19
+ */
20
+ export const JustMinted: Story = {};
21
+
22
+ /** A rotation says the same thing about a secret that already existed. */
23
+ export const Rotated: Story = {
24
+ args: {
25
+ client: {
26
+ ...MINTED_CLIENT,
27
+ notice: 'The previous secret stopped working the moment this was minted.',
28
+ },
29
+ },
30
+ };
31
+
32
+ /** Without a secret there is nothing to reveal. */
33
+ export const NothingToShow: Story = { args: { client: CLIENT } };
@@ -0,0 +1,101 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { fireEvent, render, screen, waitFor } from '../../testing';
3
+ import React from 'react';
4
+
5
+ import SecretReveal from './SecretReveal';
6
+ import type { OAuthClient } from '../../types';
7
+
8
+ const CLIENT: OAuthClient = {
9
+ '@id': '/@identity-clients/app',
10
+ client_id: 'app',
11
+ title: 'Example App',
12
+ redirect_uris: [],
13
+ grant_types: ['authorization_code'],
14
+ scope: [],
15
+ auth_method: 'client_secret_post',
16
+ public: false,
17
+ enabled: true,
18
+ service_user: '',
19
+ secret: 's3cr3t',
20
+ notice: 'This is the only time this secret is shown.',
21
+ };
22
+
23
+ function renderReveal(client: OAuthClient = CLIENT) {
24
+ const onDismiss = vi.fn();
25
+ render(<SecretReveal client={client} onDismiss={onDismiss} />);
26
+ return onDismiss;
27
+ }
28
+
29
+ describe('SecretReveal', () => {
30
+ beforeEach(() => {
31
+ Object.assign(navigator, {
32
+ clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
33
+ });
34
+ });
35
+
36
+ it('shows the secret', () => {
37
+ renderReveal();
38
+
39
+ expect(screen.getByDisplayValue('s3cr3t')).toBeTruthy();
40
+ });
41
+
42
+ it('carries the server’s own notice', () => {
43
+ renderReveal();
44
+
45
+ expect(screen.getByText(/only time this secret is shown/)).toBeTruthy();
46
+ });
47
+
48
+ it('says so even when the server sent no notice', () => {
49
+ renderReveal({ ...CLIENT, notice: undefined });
50
+
51
+ expect(screen.getByText(/only time this secret is shown/)).toBeTruthy();
52
+ });
53
+
54
+ it('announces itself, because dismissing it loses the secret', () => {
55
+ renderReveal();
56
+
57
+ expect(screen.getByRole('alertdialog')).toBeTruthy();
58
+ });
59
+
60
+ it('copies to the clipboard', async () => {
61
+ renderReveal();
62
+
63
+ fireEvent.click(screen.getByText('Copy'));
64
+
65
+ await waitFor(() =>
66
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('s3cr3t'),
67
+ );
68
+ await waitFor(() => expect(screen.getByText('Copied')).toBeTruthy());
69
+ });
70
+
71
+ it('stays usable when the browser refuses clipboard access', async () => {
72
+ Object.assign(navigator, {
73
+ clipboard: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
74
+ });
75
+ renderReveal();
76
+
77
+ fireEvent.click(screen.getByText('Copy'));
78
+
79
+ // Still readable and selectable, which is the fallback that matters.
80
+ await waitFor(() => expect(screen.getByText('Copy')).toBeTruthy());
81
+ expect(screen.getByDisplayValue('s3cr3t')).toBeTruthy();
82
+ });
83
+
84
+ it('selects the whole secret on focus', () => {
85
+ renderReveal();
86
+ const field = screen.getByDisplayValue('s3cr3t') as HTMLInputElement;
87
+ field.select = vi.fn();
88
+
89
+ fireEvent.focus(field);
90
+
91
+ expect(field.select).toHaveBeenCalled();
92
+ });
93
+
94
+ it('is dismissed only when the operator says they have saved it', () => {
95
+ const onDismiss = renderReveal();
96
+
97
+ fireEvent.click(screen.getByText('I have saved it'));
98
+
99
+ expect(onDismiss).toHaveBeenCalled();
100
+ });
101
+ });