@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,62 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import React from 'react';
3
+
4
+ import {
5
+ Pluggable,
6
+ PluggablesProvider,
7
+ } from '@plone/volto/components/manage/Pluggable';
8
+ import ApplicationsMenuItem from './ApplicationsMenuItem';
9
+ import { withPersonalTools } from '../../storybook/withUserMenu';
10
+ import { LOADED, USER, withStore } from '../../stories/fixtures';
11
+
12
+ const meta: Meta<typeof ApplicationsMenuItem> = {
13
+ title: 'Identity/UserMenu/ApplicationsMenuItem',
14
+ component: ApplicationsMenuItem,
15
+ decorators: [withPersonalTools],
16
+ parameters: { fullBleed: true },
17
+ };
18
+ export default meta;
19
+
20
+ type Story = StoryObj<typeof ApplicationsMenuItem>;
21
+
22
+ /**
23
+ * The plug renders nothing on its own -- it registers a renderer -- so every
24
+ * story includes the pluggable that consumes it, the way the user menu does.
25
+ */
26
+ const inTheMenu = () => (
27
+ <PluggablesProvider>
28
+ <ApplicationsMenuItem />
29
+ <ul>
30
+ <Pluggable name="toolbar-user-menu" />
31
+ </ul>
32
+ </PluggablesProvider>
33
+ );
34
+
35
+ const withGrants = (grants: Record<string, unknown>) =>
36
+ withStore({
37
+ userProfile: { ...LOADED, data: USER },
38
+ oauthGrants: grants,
39
+ });
40
+
41
+ /** A site running the `[server]` layer: the endpoint answered. */
42
+ export const OnAnAuthorizationServer: Story = {
43
+ render: inTheMenu,
44
+ decorators: [withGrants({ ...LOADED, data: { items: [] } })],
45
+ };
46
+
47
+ /**
48
+ * A site without that layer.
49
+ *
50
+ * Nothing publishes `@oauth-grants` there, so the entry is absent rather
51
+ * than leading to a page that can only report a failure.
52
+ */
53
+ export const OnAnOrdinarySite: Story = {
54
+ render: inTheMenu,
55
+ decorators: [withGrants({ loaded: false, error: new Error('404') })],
56
+ };
57
+
58
+ /** Before the answer arrives. */
59
+ export const StillAsking: Story = {
60
+ render: inTheMenu,
61
+ decorators: [withGrants({ loading: true, loaded: false, error: null })],
62
+ };
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { render, screen } from '../../testing';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import { Provider } from 'react-redux';
5
+ import React from 'react';
6
+
7
+ import {
8
+ Pluggable,
9
+ PluggablesProvider,
10
+ } from '@plone/volto/components/manage/Pluggable';
11
+ import ApplicationsMenuItem from './ApplicationsMenuItem';
12
+
13
+ interface State {
14
+ /** The signed-in user, or nobody. */
15
+ userid?: string | null;
16
+ /** The `@oauth-grants` request state. */
17
+ grants?: Record<string, unknown>;
18
+ }
19
+
20
+ /**
21
+ * Render the plug the way Volto's user menu does.
22
+ *
23
+ * @param state What the store says.
24
+ * @returns The dispatch spy, so a test can see whether it asked.
25
+ */
26
+ function renderInMenu(state: State = {}) {
27
+ const { userid = 'alice', grants = {} } = state;
28
+ const dispatch = vi.fn();
29
+ const store = {
30
+ getState: () => ({
31
+ userProfile: { data: userid ? { id: userid } : null },
32
+ oauthGrants: grants,
33
+ }),
34
+ dispatch,
35
+ subscribe: () => () => {},
36
+ };
37
+ render(
38
+ <Provider store={store as never}>
39
+ <MemoryRouter>
40
+ <PluggablesProvider>
41
+ <ApplicationsMenuItem />
42
+ <ul>
43
+ <Pluggable name="toolbar-user-menu" />
44
+ </ul>
45
+ </PluggablesProvider>
46
+ </MemoryRouter>
47
+ </Provider>,
48
+ );
49
+ return { dispatch };
50
+ }
51
+
52
+ /** The entry, if it rendered. */
53
+ const entry = () => screen.queryByRole('link', { name: 'Applications' });
54
+
55
+ describe('ApplicationsMenuItem', () => {
56
+ it('links to the applications page once the endpoint answered', () => {
57
+ renderInMenu({ grants: { loaded: true } });
58
+
59
+ expect(entry()?.getAttribute('href')).toBe('/applications');
60
+ });
61
+
62
+ it('renders nothing on a site without the [server] layer', () => {
63
+ // Nothing publishes `@oauth-grants` there, so the entry would lead to a
64
+ // page that can only report a failure.
65
+ renderInMenu({ grants: { error: new Error('404') } });
66
+
67
+ expect(entry()).toBeNull();
68
+ });
69
+
70
+ it('renders nothing before the answer arrives', () => {
71
+ renderInMenu({ grants: { loading: true } });
72
+
73
+ expect(entry()).toBeNull();
74
+ });
75
+
76
+ it('asks the endpoint once somebody is signed in', () => {
77
+ const { dispatch } = renderInMenu();
78
+
79
+ expect(dispatch).toHaveBeenCalled();
80
+ });
81
+
82
+ it('asks nothing for an anonymous visitor', () => {
83
+ // `appExtras` mounts this on every route, logged in or not, and asking
84
+ // would be a guaranteed 401 on every anonymous page view.
85
+ const { dispatch } = renderInMenu({ userid: null });
86
+
87
+ expect(dispatch).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('does not ask again once it has an answer', () => {
91
+ const { dispatch } = renderInMenu({ grants: { loaded: true } });
92
+
93
+ expect(dispatch).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('does not retry a site that answered 404', () => {
97
+ // Retrying on every render would be a request loop rather than a
98
+ // feature detection.
99
+ const { dispatch } = renderInMenu({ grants: { error: new Error('404') } });
100
+
101
+ expect(dispatch).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('does not ask twice while one request is in flight', () => {
105
+ const { dispatch } = renderInMenu({ grants: { loading: true } });
106
+
107
+ expect(dispatch).not.toHaveBeenCalled();
108
+ });
109
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A link to the authorized-applications page, plugged into Volto's user menu.
3
+ *
4
+ * Ordered right after "Sign-in methods", because it is the same question
5
+ * asked the other way round: that entry is what you sign in *with*, this one
6
+ * is what you have signed in *to*. Between it and Site Setup, which the
7
+ * ten-spaced orders in :mod:`components/UserMenu/UserMenuPlugs` exist to
8
+ * allow without renumbering anything.
9
+ *
10
+ * **Shown only where it leads somewhere.** `@oauth-grants` is published by
11
+ * the `[server]` layer alone, so on a site that never became an
12
+ * authorization server the route would render a page that can only report a
13
+ * failure. There is nothing in the user payload that says whether that layer
14
+ * is installed -- `core` may not depend on `server`, so its serializer
15
+ * cannot mention it -- and rather than reach across that boundary this asks
16
+ * the endpoint itself, once, and shows the entry when it answered.
17
+ *
18
+ * The cost is one request per session: on a site with the layer it is the
19
+ * data the page needs anyway, and on a site without it, one 404 that nobody
20
+ * sees. The alternative was a menu entry leading to an error page, which is
21
+ * the thing this package has refused everywhere else.
22
+ * @module components/UserMenu/ApplicationsMenuItem
23
+ */
24
+ import React, { useEffect } from 'react';
25
+ import { useDispatch, useSelector } from 'react-redux';
26
+ import { defineMessages, useIntl } from 'react-intl';
27
+ import { Plug } from '@plone/volto/components/manage/Pluggable';
28
+
29
+ import { listGrants } from '../../actions';
30
+ import { APPLICATIONS_PATH } from '../../config/routes';
31
+ import MenuItem from './MenuItem';
32
+
33
+ const messages = defineMessages({
34
+ title: { id: 'Applications', defaultMessage: 'Applications' },
35
+ });
36
+
37
+ const ApplicationsMenuItem: React.FC = () => {
38
+ const intl = useIntl();
39
+ const dispatch = useDispatch();
40
+ const userid = useSelector((state: any) => state.userProfile?.data?.id);
41
+ const loaded = useSelector((state: any) =>
42
+ Boolean(state.oauthGrants?.loaded),
43
+ );
44
+ const pending = useSelector((state: any) =>
45
+ Boolean(state.oauthGrants?.loading),
46
+ );
47
+ const failed = useSelector((state: any) => Boolean(state.oauthGrants?.error));
48
+
49
+ useEffect(() => {
50
+ // Only once somebody is signed in: anonymous has authorized nothing, and
51
+ // asking would be a guaranteed 401 on every anonymous page view.
52
+ //
53
+ // `loaded` and `error` both stop it. A site without the `[server]` layer
54
+ // answers 404, and retrying that on every render would be a request loop
55
+ // rather than a feature detection.
56
+ if (!userid || loaded || pending || failed) {
57
+ return;
58
+ }
59
+ dispatch(listGrants());
60
+ }, [dispatch, userid, loaded, pending, failed]);
61
+
62
+ if (!loaded) {
63
+ return null;
64
+ }
65
+
66
+ return (
67
+ <Plug
68
+ pluggable="toolbar-user-menu"
69
+ id="applications"
70
+ order={35}
71
+ dependencies={[loaded]}
72
+ >
73
+ <MenuItem
74
+ id="toolbar-applications"
75
+ label={intl.formatMessage(messages.title)}
76
+ to={APPLICATIONS_PATH}
77
+ />
78
+ </Plug>
79
+ );
80
+ };
81
+
82
+ export default ApplicationsMenuItem;
@@ -0,0 +1,35 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import React from 'react';
3
+
4
+ import {
5
+ Pluggable,
6
+ PluggablesProvider,
7
+ } from '@plone/volto/components/manage/Pluggable';
8
+ import IdentitiesMenuItem from './IdentitiesMenuItem';
9
+ import { withPersonalTools } from '../../storybook/withUserMenu';
10
+
11
+ const meta: Meta<typeof IdentitiesMenuItem> = {
12
+ title: 'Identity/UserMenu/IdentitiesMenuItem',
13
+ component: IdentitiesMenuItem,
14
+ decorators: [withPersonalTools],
15
+ parameters: { fullBleed: true },
16
+ };
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof IdentitiesMenuItem>;
20
+
21
+ /**
22
+ * The plug renders nothing on its own -- it registers a renderer -- so the
23
+ * story has to include the pluggable that consumes it, the way Volto's user
24
+ * menu does.
25
+ */
26
+ export const InTheUserMenu: Story = {
27
+ render: () => (
28
+ <PluggablesProvider>
29
+ <IdentitiesMenuItem />
30
+ <ul>
31
+ <Pluggable name="toolbar-user-menu" />
32
+ </ul>
33
+ </PluggablesProvider>
34
+ ),
35
+ };
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { render, screen } from '../../testing';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import React from 'react';
5
+
6
+ import {
7
+ Pluggable,
8
+ PluggablesProvider,
9
+ } from '@plone/volto/components/manage/Pluggable';
10
+ import IdentitiesMenuItem from './IdentitiesMenuItem';
11
+ import install from '../../config/menu';
12
+ import { IDENTITIES_PATH } from '../../config/routes';
13
+
14
+ /**
15
+ * Render the plug the way Volto's user menu does: inside a provider, with the
16
+ * matching `Pluggable` standing in for the one at the end of `PersonalTools`'
17
+ * list. Rendering the plug on its own would prove nothing -- a `Plug` returns
18
+ * null and registers a renderer, so the assertion has to come from the
19
+ * pluggable that consumes it.
20
+ */
21
+ function renderInMenu() {
22
+ render(
23
+ <MemoryRouter>
24
+ <PluggablesProvider>
25
+ <IdentitiesMenuItem />
26
+ <ul>
27
+ <Pluggable name="toolbar-user-menu" />
28
+ </ul>
29
+ </PluggablesProvider>
30
+ </MemoryRouter>,
31
+ );
32
+ }
33
+
34
+ describe('IdentitiesMenuItem', () => {
35
+ it('renders a link into the identities view', () => {
36
+ renderInMenu();
37
+
38
+ const link = screen.getByRole('link', { name: /sign-in methods/i });
39
+
40
+ expect(link.getAttribute('href')).toBe(IDENTITIES_PATH);
41
+ });
42
+
43
+ it('renders as a list item, because it lands inside the menu list', () => {
44
+ renderInMenu();
45
+
46
+ expect(screen.getByRole('listitem').tagName).toBe('LI');
47
+ });
48
+
49
+ it('renders nothing without a pluggable to land in', () => {
50
+ render(
51
+ <MemoryRouter>
52
+ <PluggablesProvider>
53
+ <IdentitiesMenuItem />
54
+ </PluggablesProvider>
55
+ </MemoryRouter>,
56
+ );
57
+
58
+ expect(screen.queryByRole('link')).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('the menu install step', () => {
63
+ it('registers the plug so it is mounted on every path', () => {
64
+ const config = { settings: { appExtras: [] } } as any;
65
+
66
+ install(config);
67
+
68
+ const entry = config.settings.appExtras.find(
69
+ (e: any) => e.component === IdentitiesMenuItem,
70
+ );
71
+ expect(entry).toBeTruthy();
72
+ // Volto hands `match` to `matchPath`, and an empty path matches every
73
+ // route -- which is what makes the plug reachable wherever the user
74
+ // menu is opened.
75
+ expect(entry.match).toBe('');
76
+ });
77
+
78
+ it('registers everything the menu needs on every path', () => {
79
+ const config = { settings: { appExtras: [] } } as any;
80
+
81
+ install(config);
82
+
83
+ // The loader, plus one per menu entry -- including the three Volto used
84
+ // to write into the list itself. The loader renders nothing: the entries
85
+ // beside it read the user it puts in the store.
86
+ expect(config.settings.appExtras).toHaveLength(7);
87
+ expect(config.settings.appExtras.every((e: any) => e.match === '')).toBe(
88
+ true,
89
+ );
90
+ });
91
+
92
+ it('keeps whatever another add-on already registered', () => {
93
+ const other = () => null;
94
+ const config = {
95
+ settings: { appExtras: [{ match: '', component: other, props: {} }] },
96
+ } as any;
97
+
98
+ install(config);
99
+
100
+ expect(config.settings.appExtras).toHaveLength(8);
101
+ expect(config.settings.appExtras[0].component).toBe(other);
102
+ });
103
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A link into the identities view, plugged into Volto's user menu.
3
+ * @module components/UserMenu/IdentitiesMenuItem
4
+ */
5
+ import React from 'react';
6
+ import { defineMessages, useIntl } from 'react-intl';
7
+ import { Plug } from '@plone/volto/components/manage/Pluggable';
8
+
9
+ import { IDENTITIES_PATH } from '../../config/routes';
10
+ import MenuItem from './MenuItem';
11
+
12
+ const messages = defineMessages({
13
+ title: { id: 'Sign-in methods', defaultMessage: 'Sign-in methods' },
14
+ });
15
+
16
+ /**
17
+ * Ordered right after Preferences, because it is one: this is where a person
18
+ * adds a way of getting in or drops one they no longer use. See
19
+ * :mod:`components/UserMenu/UserMenuPlugs` for the whole ordering.
20
+ */
21
+ const IdentitiesMenuItem: React.FC = () => {
22
+ const intl = useIntl();
23
+ return (
24
+ <Plug pluggable="toolbar-user-menu" id="identities" order={30}>
25
+ <MenuItem
26
+ id="toolbar-identities"
27
+ label={intl.formatMessage(messages.title)}
28
+ to={IDENTITIES_PATH}
29
+ />
30
+ </Plug>
31
+ );
32
+ };
33
+
34
+ export default IdentitiesMenuItem;
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import MenuItem from './MenuItem';
3
+ import { withMenuList, withPersonalTools } from '../../storybook/withUserMenu';
4
+
5
+ /**
6
+ * One entry of the personal-tools menu.
7
+ *
8
+ * Shown inside a `<ul>` because that is where it lands: it renders the
9
+ * `<li>` itself, and the menu's stylesheet is written against that nesting.
10
+ */
11
+ const meta: Meta<typeof MenuItem> = {
12
+ title: 'Identity/UserMenu/MenuItem',
13
+ component: MenuItem,
14
+ args: { id: 'toolbar-example', label: 'Preferences' },
15
+ // Innermost first, so this reads in the order the DOM nests.
16
+ decorators: [withMenuList, withPersonalTools],
17
+ parameters: { fullBleed: true },
18
+ };
19
+ export default meta;
20
+
21
+ type Story = StoryObj<typeof MenuItem>;
22
+
23
+ /** Most entries go somewhere. */
24
+ export const ALink: Story = { args: { to: '/personal-information' } };
25
+
26
+ /** Preferences does not: it slides another panel over the toolbar. */
27
+ export const AButton: Story = { args: { onClick: () => {} } };
28
+
29
+ /** A label long enough to meet the arrow. */
30
+ export const LongLabel: Story = {
31
+ args: { label: 'Sign-in methods and connected accounts', to: '/identities' },
32
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * One entry in the personal-tools menu.
3
+ *
4
+ * Every entry in that menu is the same thing -- a label, a right arrow, and
5
+ * either a route to go to or a panel to slide in -- and there are now five of
6
+ * them, each registered separately so a site can reorder or remove any one.
7
+ * Written once here so that "the same thing" stays true: five copies of this
8
+ * markup would drift, and the menu's stylesheet keys on it.
9
+ *
10
+ * It renders the `<li>` because it lands inside the menu's own `<ul>`.
11
+ * @module components/UserMenu/MenuItem
12
+ */
13
+ import React from 'react';
14
+ import { Link } from 'react-router-dom';
15
+ import Icon from '@plone/volto/components/theme/Icon/Icon';
16
+ import rightArrowSVG from '@plone/volto/icons/right-key.svg';
17
+
18
+ interface MenuItemProps {
19
+ /** DOM id, so a stylesheet or a test can name this entry. */
20
+ id: string;
21
+ /** The visible label, already translated. */
22
+ label: string;
23
+ /** Where it goes. Mutually exclusive with `onClick`. */
24
+ to?: string;
25
+ /** What it does instead of going anywhere -- sliding a panel in. */
26
+ onClick?: () => void;
27
+ }
28
+
29
+ const MenuItem: React.FC<MenuItemProps> = ({ id, label, to, onClick }) => (
30
+ <li>
31
+ {to ? (
32
+ <Link id={id} to={to}>
33
+ {label}
34
+ <Icon name={rightArrowSVG} size="24px" />
35
+ </Link>
36
+ ) : (
37
+ // `aria-label` as well as the text: the arrow is inside the button, and
38
+ // an accessible name assembled from the label plus an icon's title is
39
+ // what a screen reader would otherwise read out.
40
+ <button id={id} aria-label={label} onClick={onClick}>
41
+ {label}
42
+ <Icon name={rightArrowSVG} size="24px" />
43
+ </button>
44
+ )}
45
+ </li>
46
+ );
47
+
48
+ export default MenuItem;
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import React from 'react';
3
+
4
+ import {
5
+ Pluggable,
6
+ PluggablesProvider,
7
+ } from '@plone/volto/components/manage/Pluggable';
8
+ import ProfileMenuItem from './ProfileMenuItem';
9
+ import { withPersonalTools } from '../../storybook/withUserMenu';
10
+ import { withUser } from '../../stories/fixtures';
11
+
12
+ const meta: Meta<typeof ProfileMenuItem> = {
13
+ title: 'Identity/UserMenu/ProfileMenuItem',
14
+ component: ProfileMenuItem,
15
+ decorators: [withPersonalTools],
16
+ parameters: { fullBleed: true },
17
+ };
18
+ export default meta;
19
+
20
+ type Story = StoryObj<typeof ProfileMenuItem>;
21
+
22
+ /**
23
+ * The plug renders nothing on its own -- it registers a renderer -- so every
24
+ * story has to include the pluggable that consumes it, the way Volto's user
25
+ * menu does.
26
+ */
27
+ const inTheMenu = () => (
28
+ <PluggablesProvider>
29
+ <ProfileMenuItem />
30
+ <ul>
31
+ <Pluggable name="toolbar-user-menu" />
32
+ </ul>
33
+ </PluggablesProvider>
34
+ );
35
+
36
+ /** A user whose account *is* a Profile content object. */
37
+ export const StoredAsAProfile: Story = {
38
+ render: inTheMenu,
39
+ decorators: [
40
+ withUser({ profile_url: 'https://example.org/identity-profiles/alice' }),
41
+ ],
42
+ };
43
+
44
+ /**
45
+ * A user who *has* a Profile but is not stored as one.
46
+ *
47
+ * Their account lives in `source_users`, so `/personal-information` is still
48
+ * where their fields are edited and Volto's own Profile entry keeps the slot.
49
+ */
50
+ export const StoredElsewhere: Story = {
51
+ render: inTheMenu,
52
+ decorators: [
53
+ withUser({
54
+ source: 'source_users',
55
+ profile_url: 'https://example.org/identity-profiles/alice',
56
+ }),
57
+ ],
58
+ };
59
+
60
+ /**
61
+ * No Profile: the entry is absent rather than disabled.
62
+ *
63
+ * Either this account predates the add-on, or first login has not minted a
64
+ * Profile for it yet. A menu entry leading nowhere is worse than no menu
65
+ * entry, which is why this renders an empty list.
66
+ */
67
+ export const WithoutAProfile: Story = {
68
+ render: inTheMenu,
69
+ decorators: [withUser({ profile_url: null })],
70
+ };
71
+
72
+ /** Before the user has loaded, there is nothing to decide on yet. */
73
+ export const Anonymous: Story = {
74
+ render: inTheMenu,
75
+ decorators: [withUser(null)],
76
+ };
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { render, screen } from '../../testing';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import { Provider } from 'react-redux';
5
+ import React from 'react';
6
+
7
+ import {
8
+ Pluggable,
9
+ PluggablesProvider,
10
+ } from '@plone/volto/components/manage/Pluggable';
11
+ import ProfileMenuItem from './ProfileMenuItem';
12
+
13
+ /**
14
+ * Render the plug the way Volto's user menu does, over a store holding
15
+ * exactly the profile state the component selects.
16
+ */
17
+ function renderInMenu(userProfile: unknown) {
18
+ const store = {
19
+ getState: () => ({ userProfile }),
20
+ dispatch: (action: unknown) => action,
21
+ subscribe: () => () => {},
22
+ };
23
+ render(
24
+ <Provider store={store as never}>
25
+ <MemoryRouter>
26
+ <PluggablesProvider>
27
+ <ProfileMenuItem />
28
+ <ul>
29
+ <Pluggable name="toolbar-user-menu" />
30
+ </ul>
31
+ </PluggablesProvider>
32
+ </MemoryRouter>
33
+ </Provider>,
34
+ );
35
+ }
36
+
37
+ const loaded = (profileUrl: string | null, source = 'identity_profile') => ({
38
+ loading: false,
39
+ loaded: true,
40
+ error: null,
41
+ data: { id: 'alice', profile_url: profileUrl, source },
42
+ });
43
+
44
+ /** The entry, if it rendered. */
45
+ const entry = () => screen.queryByRole('link', { name: 'Profile' });
46
+
47
+ describe('ProfileMenuItem', () => {
48
+ it('links to the Profile of a user whose account is one', () => {
49
+ renderInMenu(loaded('http://localhost:8080/Plone/identity-profiles/alice'));
50
+
51
+ // Flattened: the store holds the backend's absolute URL, and a
52
+ // react-router link has to be a path on this site.
53
+ expect(entry()?.getAttribute('href')).toBe('/identity-profiles/alice');
54
+ });
55
+
56
+ it('renders nothing when the user has no Profile', () => {
57
+ // An account that predates the add-on, or a user first login has not
58
+ // minted one for. An entry leading nowhere is worse than no entry.
59
+ renderInMenu(loaded(null));
60
+
61
+ expect(entry()).toBeNull();
62
+ });
63
+
64
+ it('does not care which plugin authenticated the account', () => {
65
+ // It never could: every account this package creates lives in
66
+ // `source_users`, and keying on that hid this entry for everybody. What
67
+ // decides is whether a Profile holds the user's fields.
68
+ renderInMenu(
69
+ loaded(
70
+ 'http://localhost:8080/Plone/identity-profiles/alice',
71
+ 'source_users',
72
+ ),
73
+ );
74
+
75
+ expect(entry()).toBeTruthy();
76
+ });
77
+
78
+ it('renders nothing before the user has loaded', () => {
79
+ renderInMenu({ loading: true, loaded: false, error: null, data: null });
80
+
81
+ expect(entry()).toBeNull();
82
+ });
83
+
84
+ it('renders nothing for an anonymous visitor', () => {
85
+ // `appExtras` mounts this on every route, logged in or not.
86
+ renderInMenu(undefined);
87
+
88
+ expect(entry()).toBeNull();
89
+ });
90
+ });