@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,202 @@
1
+ /**
2
+ * The personal-tools menu as an ordered list of plugs.
3
+ *
4
+ * The entries are registered from five separate components, so what nobody
5
+ * can see by reading any one of them is the thing worth testing: what the
6
+ * assembled menu contains, in what order, and which entries stand each other
7
+ * down.
8
+ */
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { fireEvent, render, screen } from '../../testing';
11
+ import { MemoryRouter } from 'react-router-dom';
12
+ import { Provider } from 'react-redux';
13
+ import React from 'react';
14
+
15
+ import {
16
+ Pluggable,
17
+ PluggablesProvider,
18
+ } from '@plone/volto/components/manage/Pluggable';
19
+ import IdentitiesMenuItem from './IdentitiesMenuItem';
20
+ import ProfileMenuItem from './ProfileMenuItem';
21
+ import {
22
+ PersonalInformationMenuItem,
23
+ PreferencesMenuItem,
24
+ SiteSetupMenuItem,
25
+ } from './UserMenuPlugs';
26
+
27
+ interface MenuState {
28
+ /** The Profile URL `@users` reported, when the user has one. */
29
+ profileUrl?: string | null;
30
+ /** The PAS plugin the userid came from, as `@users` reports it. */
31
+ source?: string | null;
32
+ /** The user actions the backend listed. */
33
+ actions?: unknown[];
34
+ /** Slide a panel in; the prop `PersonalTools` passes through params. */
35
+ loadComponent?: (selector: string) => void;
36
+ }
37
+
38
+ /**
39
+ * Render the whole menu the way the application assembles it.
40
+ *
41
+ * Every plug is mounted from `appExtras` in the real thing, which is what
42
+ * this stands in for; the `<Pluggable>` is what the shadowed `PersonalTools`
43
+ * renders.
44
+ *
45
+ * @param state What the store says and what the toolbar passes down.
46
+ * @returns The ids of the entries, in the order they were rendered.
47
+ */
48
+ function renderMenu(state: MenuState = {}) {
49
+ const {
50
+ profileUrl = null,
51
+ source = null,
52
+ actions = [],
53
+ loadComponent = vi.fn(),
54
+ } = state;
55
+ const store = {
56
+ getState: () => ({
57
+ userProfile: { data: { id: 'alice', profile_url: profileUrl, source } },
58
+ actions: { actions: { user: actions } },
59
+ }),
60
+ dispatch: vi.fn(),
61
+ subscribe: () => () => {},
62
+ };
63
+
64
+ render(
65
+ <Provider store={store as never}>
66
+ <MemoryRouter>
67
+ <PluggablesProvider>
68
+ <PersonalInformationMenuItem />
69
+ <PreferencesMenuItem />
70
+ <SiteSetupMenuItem />
71
+ <IdentitiesMenuItem />
72
+ <ProfileMenuItem />
73
+ <ul>
74
+ <Pluggable name="toolbar-user-menu" params={{ loadComponent }} />
75
+ </ul>
76
+ </PluggablesProvider>
77
+ </MemoryRouter>
78
+ </Provider>,
79
+ );
80
+
81
+ return {
82
+ loadComponent,
83
+ ids: Array.from(document.querySelectorAll('li > *')).map((el) => el.id),
84
+ };
85
+ }
86
+
87
+ /** A user whose account *is* a Profile content object. */
88
+ const STORED_AS_A_PROFILE: MenuState = {
89
+ profileUrl: 'http://site/profiles/alice',
90
+ source: 'identity_profile',
91
+ };
92
+
93
+ describe('the personal-tools menu', () => {
94
+ it('puts sign-in methods straight after preferences', () => {
95
+ // Which is where it belongs: choosing how you get in is a preference,
96
+ // and before this the pluggable could only append after Site Setup.
97
+ const { ids } = renderMenu();
98
+
99
+ expect(ids).toEqual([
100
+ 'toolbar-profile',
101
+ 'toolbar-preferences',
102
+ 'toolbar-identities',
103
+ ]);
104
+ });
105
+
106
+ it('puts Site Setup last, for somebody who has that action', () => {
107
+ // Last because it is about the site rather than about the person.
108
+ const { ids } = renderMenu({ actions: [{ id: 'plone_setup' }] });
109
+
110
+ expect(ids[ids.length - 1]).toBe('toolbar-site-setup');
111
+ });
112
+
113
+ it('offers Site Setup to nobody else', () => {
114
+ const { ids } = renderMenu();
115
+
116
+ expect(ids).not.toContain('toolbar-site-setup');
117
+ });
118
+
119
+ it('gives the Profile slot to the object the account lives in', () => {
120
+ // Not beside it: two entries both called "Profile" make the reader guess
121
+ // which one they want.
122
+ const { ids } = renderMenu(STORED_AS_A_PROFILE);
123
+
124
+ expect(ids).toContain('toolbar-identity-profile');
125
+ expect(ids).not.toContain('toolbar-profile');
126
+ });
127
+
128
+ it('keeps it where the member form was', () => {
129
+ const { ids } = renderMenu(STORED_AS_A_PROFILE);
130
+
131
+ expect(ids).toEqual([
132
+ 'toolbar-identity-profile',
133
+ 'toolbar-preferences',
134
+ 'toolbar-identities',
135
+ ]);
136
+ });
137
+
138
+ it('calls it Profile either way', () => {
139
+ // It stands in the other one's place rather than beside it, so it takes
140
+ // its name too.
141
+ renderMenu(STORED_AS_A_PROFILE);
142
+ expect(screen.getByText('Profile')).toBeTruthy();
143
+
144
+ document.body.innerHTML = '';
145
+ renderMenu();
146
+ expect(screen.getByText('Profile')).toBeTruthy();
147
+ });
148
+
149
+ it('does not care which plugin authenticated the account', () => {
150
+ // `source` names whichever plugin PAS enumerated the account from, and
151
+ // that changes with how the account was made -- so a rule keyed on it
152
+ // hid the entry for whole classes of user. `profile_url` is the
153
+ // question the menu actually has.
154
+ const { ids } = renderMenu({
155
+ profileUrl: 'http://site/profiles/alice',
156
+ source: 'source_users',
157
+ });
158
+
159
+ expect(ids).toContain('toolbar-identity-profile');
160
+ expect(ids).not.toContain('toolbar-profile');
161
+ });
162
+
163
+ it('falls back to the member form when there is no Profile', () => {
164
+ // An account that predates the add-on, or a user whose first login has
165
+ // not minted a Profile yet.
166
+ const { ids } = renderMenu();
167
+
168
+ expect(ids).toContain('toolbar-profile');
169
+ expect(ids).not.toContain('toolbar-identity-profile');
170
+ });
171
+
172
+ it('needs a Profile to link to', () => {
173
+ // A menu entry leading nowhere is worse than no menu entry.
174
+ const { ids } = renderMenu({ source: 'identity_profile' });
175
+
176
+ expect(ids).toContain('toolbar-profile');
177
+ expect(ids).not.toContain('toolbar-identity-profile');
178
+ });
179
+
180
+ it('gives every entry an id that does not change with the language', () => {
181
+ // Upstream used the translated label as the DOM id, so anything keying
182
+ // on it worked in English and silently not in Portuguese.
183
+ const { ids } = renderMenu({
184
+ ...STORED_AS_A_PROFILE,
185
+ actions: [{ id: 'plone_setup' }],
186
+ });
187
+
188
+ for (const id of ids) {
189
+ expect(id.startsWith('toolbar-')).toBe(true);
190
+ }
191
+ });
192
+
193
+ it('slides the preferences panel in through the toolbar it came from', () => {
194
+ // The one entry that is not a link. `loadComponent` belongs to
195
+ // `PersonalTools`, and reaches this plug through the pluggable's params.
196
+ const { loadComponent } = renderMenu();
197
+
198
+ fireEvent.click(screen.getByRole('button', { name: 'Preferences' }));
199
+
200
+ expect(loadComponent).toHaveBeenCalledWith('preferences');
201
+ });
202
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Volto's own personal-tools entries, re-registered as plugs.
3
+ *
4
+ * The menu used to be three entries written into `PersonalTools` plus a
5
+ * pluggable at the end, which meant an add-on could only ever *append*: a
6
+ * site wanting "Sign-in methods" under "Preferences", or wanting no "Site
7
+ * Setup" link, had to shadow the component again. Here every entry is a plug
8
+ * with an `order`, including the ones Volto ships, so the whole menu is one
9
+ * ordered list a site composes rather than three fixed things and a bucket.
10
+ *
11
+ * The orders are spaced by ten, so an add-on can land between two of them
12
+ * without renumbering anything:
13
+ *
14
+ * ==== ==========================================================
15
+ * 10 The profile -- this package's, when the user has one;
16
+ * Volto's member form otherwise. Never both: see below.
17
+ * 20 Preferences
18
+ * 30 Sign-in methods, which is asked for right after preferences
19
+ * because it *is* a preference: it is where a person adds or
20
+ * drops a way of getting in.
21
+ * 35 Applications, on a site running the `[server]` layer -- the
22
+ * same question the other way round, so it sits next to it.
23
+ * A number between two of these is exactly what the spacing
24
+ * is for.
25
+ * 40 Site Setup, for a manager. Last because it is about the
26
+ * site rather than about the person.
27
+ * ==== ==========================================================
28
+ *
29
+ * `loadComponent` reaches these through the pluggable's `params`, which is
30
+ * how a plug rendered from `appExtras` gets at a prop that belongs to
31
+ * `PersonalTools`. A plug that needs it takes a function child; one that does
32
+ * not takes plain children and never sees them.
33
+ * @module components/UserMenu/UserMenuPlugs
34
+ */
35
+ import React from 'react';
36
+ import { useSelector } from 'react-redux';
37
+ import { defineMessages, useIntl } from 'react-intl';
38
+ import { Plug } from '@plone/volto/components/manage/Pluggable';
39
+
40
+ import { profileHoldsTheFields } from '../../helpers/profileSource';
41
+ import MenuItem from './MenuItem';
42
+
43
+ const messages = defineMessages({
44
+ preferences: { id: 'Preferences', defaultMessage: 'Preferences' },
45
+ profile: { id: 'Profile', defaultMessage: 'Profile' },
46
+ siteSetup: { id: 'Site Setup', defaultMessage: 'Site Setup' },
47
+ });
48
+
49
+ /** Params the personal-tools pluggable hands to every plug. */
50
+ export interface UserMenuParams {
51
+ /** Slide another panel into the toolbar, by its registered name. */
52
+ loadComponent: (selector: string) => void;
53
+ }
54
+
55
+ /**
56
+ * Volto's own Profile entry, which is the member form at
57
+ * `/personal-information`.
58
+ *
59
+ * Absent for a user whose fields are held in a Profile, where
60
+ * `ProfileMenuItem` takes this slot under the same name. It keeps the slot
61
+ * for a user first login has not minted a Profile for, and for an account
62
+ * that predates the add-on -- who are exactly the users whose fields really
63
+ * are edited at `/personal-information`.
64
+ */
65
+ export const PersonalInformationMenuItem: React.FC = () => {
66
+ const intl = useIntl();
67
+ // A primitive; see `ProfileMenuItem` for why.
68
+ const profileUrl = useSelector(
69
+ (state: any) => state.userProfile?.data?.profile_url,
70
+ );
71
+
72
+ if (profileHoldsTheFields(profileUrl)) {
73
+ return null;
74
+ }
75
+
76
+ return (
77
+ <Plug
78
+ pluggable="toolbar-user-menu"
79
+ id="personal-information"
80
+ order={10}
81
+ dependencies={[profileUrl]}
82
+ >
83
+ <MenuItem
84
+ id="toolbar-profile"
85
+ label={intl.formatMessage(messages.profile)}
86
+ to="/personal-information"
87
+ />
88
+ </Plug>
89
+ );
90
+ };
91
+
92
+ /** Volto's Preferences panel, which slides in rather than being a route. */
93
+ export const PreferencesMenuItem: React.FC = () => {
94
+ const intl = useIntl();
95
+ return (
96
+ <Plug pluggable="toolbar-user-menu" id="preferences" order={20}>
97
+ {({ loadComponent }: UserMenuParams) => (
98
+ <MenuItem
99
+ id="toolbar-preferences"
100
+ label={intl.formatMessage(messages.preferences)}
101
+ onClick={() => loadComponent('preferences')}
102
+ />
103
+ )}
104
+ </Plug>
105
+ );
106
+ };
107
+
108
+ /**
109
+ * Volto's Site Setup link, for a user who may see it.
110
+ *
111
+ * The condition is upstream's: the `plone_setup` user action, which the
112
+ * backend only lists for somebody allowed to reach the control panels. Read
113
+ * from the store here rather than passed through `params`, so this plug does
114
+ * not need `PersonalTools` to know about it.
115
+ */
116
+ export const SiteSetupMenuItem: React.FC = () => {
117
+ const intl = useIntl();
118
+ const siteSetupAction = useSelector((state: any) =>
119
+ state.actions?.actions?.user?.find(
120
+ (action: { id?: string }) => action?.id === 'plone_setup',
121
+ ),
122
+ );
123
+
124
+ if (!siteSetupAction) {
125
+ return null;
126
+ }
127
+
128
+ return (
129
+ <Plug
130
+ pluggable="toolbar-user-menu"
131
+ id="site-setup"
132
+ order={40}
133
+ dependencies={[Boolean(siteSetupAction)]}
134
+ >
135
+ <MenuItem
136
+ id="toolbar-site-setup"
137
+ label={intl.formatMessage(messages.siteSetup)}
138
+ to="/controlpanel"
139
+ />
140
+ </Plug>
141
+ );
142
+ };
@@ -0,0 +1,211 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { render } from '../../testing';
3
+ import { Provider } from 'react-redux';
4
+ import React from 'react';
5
+
6
+ import UserProfileLoader from './UserProfileLoader';
7
+
8
+ /** A JWT-shaped token whose payload names `sub`. */
9
+ function tokenFor(sub: string): string {
10
+ const body = Buffer.from(JSON.stringify({ sub, exp: 9e9 }))
11
+ .toString('base64')
12
+ .replace(/\+/g, '-')
13
+ .replace(/\//g, '_')
14
+ .replace(/=+$/, '');
15
+ return `header.${body}.signature`;
16
+ }
17
+
18
+ function renderLoader(state: Record<string, unknown>) {
19
+ const dispatch = vi.fn((action: unknown) => action);
20
+ const store = {
21
+ getState: () => state,
22
+ dispatch,
23
+ subscribe: () => () => {},
24
+ };
25
+ render(
26
+ <Provider store={store as never}>
27
+ <UserProfileLoader />
28
+ </Provider>,
29
+ );
30
+ return dispatch;
31
+ }
32
+
33
+ const EMPTY = { loading: false, loaded: false, error: null, data: null };
34
+
35
+ describe('UserProfileLoader', () => {
36
+ it('asks for the signed-in user', () => {
37
+ const dispatch = renderLoader({
38
+ userSession: { token: tokenFor('alice') },
39
+ userProfile: EMPTY,
40
+ });
41
+
42
+ expect(dispatch).toHaveBeenCalledWith(
43
+ expect.objectContaining({
44
+ request: { op: 'get', path: '/@users/alice' },
45
+ }),
46
+ );
47
+ });
48
+
49
+ it('asks for nothing when nobody is signed in', () => {
50
+ const dispatch = renderLoader({
51
+ userSession: { token: '' },
52
+ userProfile: EMPTY,
53
+ });
54
+
55
+ expect(dispatch).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('does not ask again for a user it already holds', () => {
59
+ const dispatch = renderLoader({
60
+ userSession: { token: tokenFor('alice') },
61
+ userProfile: {
62
+ loading: false,
63
+ loaded: true,
64
+ error: null,
65
+ data: { id: 'alice' },
66
+ },
67
+ });
68
+
69
+ expect(dispatch).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('does not ask while a request is in flight', () => {
73
+ const dispatch = renderLoader({
74
+ userSession: { token: tokenFor('alice') },
75
+ userProfile: { ...EMPTY, loading: true },
76
+ });
77
+
78
+ expect(dispatch).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('asks again when a different user signs in', () => {
82
+ const dispatch = renderLoader({
83
+ userSession: { token: tokenFor('bob') },
84
+ userProfile: {
85
+ loading: false,
86
+ loaded: true,
87
+ error: null,
88
+ data: { id: 'alice' },
89
+ },
90
+ });
91
+
92
+ expect(dispatch).toHaveBeenCalledWith(
93
+ expect.objectContaining({
94
+ request: { op: 'get', path: '/@users/bob' },
95
+ }),
96
+ );
97
+ });
98
+
99
+ it('survives a store where nothing has been initialised yet', () => {
100
+ // Both slices are read with optional chaining because this mounts on
101
+ // every route, including the first render of a cold store.
102
+ const dispatch = renderLoader({});
103
+
104
+ expect(dispatch).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('does not retry forever after a failure', () => {
108
+ // The request was refused -- 401 on a stale token, say. Retrying on
109
+ // every render would be a request per frame.
110
+ const dispatch = renderLoader({
111
+ userSession: { token: tokenFor('alice') },
112
+ userProfile: {
113
+ loading: false,
114
+ loaded: false,
115
+ error: { status: 401 },
116
+ data: null,
117
+ },
118
+ });
119
+
120
+ expect(dispatch).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ it('does not ask again after the request failed', () => {
124
+ // The storm, reproduced the way it actually happened: the request fails,
125
+ // `loading` goes true then false again, and a guard made only of
126
+ // `loading` and `loadedFor` is satisfied on the very next render. One
127
+ // render cannot show this — the effect has to re-run, which is what the
128
+ // oscillation below causes.
129
+ const dispatch = vi.fn((action: unknown) => action);
130
+ const state: Record<string, unknown> = {
131
+ userSession: { token: tokenFor('alice') },
132
+ userProfile: { ...EMPTY },
133
+ };
134
+ const store = {
135
+ getState: () => state,
136
+ dispatch,
137
+ subscribe: () => () => {},
138
+ };
139
+ const { rerender } = render(
140
+ <Provider store={store as never}>
141
+ <UserProfileLoader />
142
+ </Provider>,
143
+ );
144
+
145
+ // Three failed round trips' worth of state changes.
146
+ for (const loading of [true, false, true, false, true, false]) {
147
+ state.userProfile = loading
148
+ ? { loading: true, loaded: false, error: null, data: null }
149
+ : { loading: false, loaded: false, error: { status: 401 }, data: null };
150
+ rerender(
151
+ <Provider store={{ ...store, getState: () => state } as never}>
152
+ <UserProfileLoader />
153
+ </Provider>,
154
+ );
155
+ }
156
+
157
+ expect(dispatch).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it('does not ask again after a failure it already saw', () => {
161
+ // The storm. A failed request leaves `loadedFor` unset and `loading`
162
+ // false, so a guard made only of those two is satisfied on the very next
163
+ // render and the effect fires again — for ever. A token that no longer
164
+ // authenticates but still decodes to a userid, which is exactly what a
165
+ // stale token against a rebuilt site is, produced about 150 requests a
166
+ // second against `@users/<id>` in the demo until the tab was closed.
167
+ const dispatch = renderLoader({
168
+ userSession: { token: tokenFor('alice') },
169
+ userProfile: {
170
+ loading: false,
171
+ loaded: false,
172
+ error: { status: 401 },
173
+ data: null,
174
+ },
175
+ });
176
+
177
+ expect(dispatch).toHaveBeenCalledTimes(1);
178
+ });
179
+
180
+ it('asks again when the user changes', () => {
181
+ // One attempt per userid, not one attempt ever: signing in as somebody
182
+ // else has to fetch somebody else.
183
+ const dispatch = renderLoader({
184
+ userSession: { token: tokenFor('alice') },
185
+ userProfile: {
186
+ loading: false,
187
+ loaded: false,
188
+ error: { status: 401 },
189
+ data: null,
190
+ },
191
+ });
192
+ const first = dispatch.mock.calls.length;
193
+
194
+ const second = renderLoader({
195
+ userSession: { token: tokenFor('bob') },
196
+ userProfile: {
197
+ loading: false,
198
+ loaded: false,
199
+ error: { status: 401 },
200
+ data: null,
201
+ },
202
+ });
203
+
204
+ expect(first).toBe(1);
205
+ expect(second).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ request: { op: 'get', path: '/@users/bob' },
208
+ }),
209
+ );
210
+ });
211
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Keep the signed-in user's profile in the store.
3
+ *
4
+ * Volto fetches the current user only when the personal-tools menu opens, and
5
+ * what it fetches lands in `state.users.user`, which the menu also clears
6
+ * around itself. Anything that wants to show who is signed in *outside* that
7
+ * menu -- an avatar in the toolbar, a link to the user's Profile -- therefore
8
+ * cannot rely on it being there.
9
+ *
10
+ * This mounts on every route through `appExtras`, renders nothing, and asks
11
+ * once per userid — once, whether the answer arrives or fails. It is the userid rather than the token that it keys on:
12
+ * a token is reissued on refresh without the user changing, and refetching
13
+ * the same person on every renewal would be a request per hour for nothing.
14
+ * @module components/UserMenu/UserProfileLoader
15
+ */
16
+ import React, { useEffect, useRef } from 'react';
17
+ import { useDispatch, useSelector } from 'react-redux';
18
+
19
+ import { getUserProfile } from '../../actions';
20
+ import { useridFromToken } from '../../helpers/token';
21
+
22
+ const UserProfileLoader: React.FC = () => {
23
+ const dispatch = useDispatch();
24
+ const token = useSelector((state: any) => state.userSession?.token);
25
+ const loadedFor = useSelector((state: any) => state.userProfile?.data?.id);
26
+ const loading = useSelector((state: any) => state.userProfile?.loading);
27
+ const userid = useridFromToken(token);
28
+ const attempted = useRef<string | null>(null);
29
+
30
+ useEffect(() => {
31
+ // Anonymous, or already holding this user. A request in flight counts as
32
+ // holding them: without that check the effect fires again on the render
33
+ // the pending state causes, and the two answers race.
34
+ if (!userid || loading || loadedFor === userid) {
35
+ return;
36
+ }
37
+ // And once per userid, however it turned out. A *failed* request leaves
38
+ // `loadedFor` unset and `loading` false, so the two checks above are both
39
+ // satisfied on the very next render and the effect fires again — for
40
+ // ever. A token that no longer authenticates but still decodes to a
41
+ // userid, which is what a stale token in a rebuilt site is, produced
42
+ // about 150 requests a second against `@users/<id>` until the tab was
43
+ // closed.
44
+ if (attempted.current === userid) {
45
+ return;
46
+ }
47
+ attempted.current = userid;
48
+ dispatch(getUserProfile(userid));
49
+ }, [dispatch, userid, loadedFor, loading]);
50
+
51
+ return null;
52
+ };
53
+
54
+ export default UserProfileLoader;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A group page: what is in it, and who.
3
+ *
4
+ * Three sections with the same rhythm, so a group nested three deep and a
5
+ * flat one read as the same page with different amounts on it.
6
+ */
7
+ .identity-group-view {
8
+ section {
9
+ margin-top: var(--identity-gap);
10
+ }
11
+
12
+ h2 {
13
+ margin-bottom: 4px;
14
+ font-size: 1.15em;
15
+ }
16
+
17
+ ul {
18
+ padding: 0;
19
+ margin: 8px 0 0;
20
+ list-style: none;
21
+ }
22
+
23
+ li {
24
+ display: flex;
25
+ align-items: baseline;
26
+ padding: 6px 0;
27
+ border-bottom: var(--identity-border);
28
+ gap: 8px;
29
+ }
30
+ }
31
+
32
+ .identity-group-view__list {
33
+ li a {
34
+ font-weight: 600;
35
+ }
36
+ }