@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,370 @@
1
+ /**
2
+ * The gate, mounted.
3
+ *
4
+ * `helpers/profileGate` already covers which path wins; what is left is the
5
+ * wiring around it, and every one of these is a way to break a site rather
6
+ * than a component. Asking for the profile while anonymous answers 401 on
7
+ * every page of a public site. Not asking at all leaves a user with an
8
+ * unfinished profile browsing freely. Redirecting before the answer arrives,
9
+ * or after an error, makes a backend hiccup look like a locked site.
10
+ */
11
+ import { describe, expect, it } from 'vitest';
12
+ import { render } from '@testing-library/react';
13
+ import React from 'react';
14
+ import { MemoryRouter, Route, useLocation } from 'react-router-dom';
15
+ import { Provider } from 'react-redux';
16
+ import { IntlProvider } from '../../testing';
17
+
18
+ import ProfileGate from './ProfileGate';
19
+ import { rememberReturn, takeReturn } from '../../helpers/profileGate';
20
+
21
+ const PROFILE = 'https://example.org/identity-profiles/alice';
22
+
23
+ const dispatched: any[] = [];
24
+
25
+ function store(state: any) {
26
+ return {
27
+ getState: () => state,
28
+ subscribe: () => () => {},
29
+ dispatch: (action: any) => {
30
+ dispatched.push(action);
31
+ return action;
32
+ },
33
+ };
34
+ }
35
+
36
+ function profileState(overrides: any = {}) {
37
+ return {
38
+ loading: false,
39
+ loaded: true,
40
+ error: null,
41
+ data: {
42
+ '@id': '/@my-profile',
43
+ userid: 'alice',
44
+ profile: PROFILE,
45
+ review_state: 'incomplete',
46
+ },
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ /** Render the gate at a path and report where the router ended up. */
52
+ function mountAt(pathname: string, state: any): { path: string } {
53
+ const seen = { path: pathname };
54
+ const Spy = () => {
55
+ seen.path = useLocation().pathname;
56
+ return null;
57
+ };
58
+ render(
59
+ <Provider store={store(state) as any}>
60
+ <IntlProvider locale="en">
61
+ <MemoryRouter initialEntries={[pathname]}>
62
+ <ProfileGate />
63
+ <Route path="*" component={Spy} />
64
+ </MemoryRouter>
65
+ </IntlProvider>
66
+ </Provider>,
67
+ );
68
+ return seen;
69
+ }
70
+
71
+ /**
72
+ * Run with a stubbed `window.location`, so a real navigation is observable.
73
+ *
74
+ * jsdom refuses to navigate and only logs; the assignment is the thing under
75
+ * test, so it has to land somewhere that can be read back.
76
+ */
77
+ function withLocation(run: () => void): void {
78
+ const original = Object.getOwnPropertyDescriptor(window, 'location');
79
+ delete (window as any).location;
80
+ (window as any).location = { href: '' };
81
+ try {
82
+ run();
83
+ } finally {
84
+ if (original) {
85
+ Object.defineProperty(window, 'location', original);
86
+ }
87
+ }
88
+ }
89
+
90
+ function withStorage(run: () => void): void {
91
+ const original = Object.getOwnPropertyDescriptor(window, 'sessionStorage');
92
+ const values = new Map<string, string>();
93
+ Object.defineProperty(window, 'sessionStorage', {
94
+ configurable: true,
95
+ value: {
96
+ getItem: (k: string) => values.get(k) ?? null,
97
+ setItem: (k: string, v: string) => values.set(k, v),
98
+ removeItem: (k: string) => values.delete(k),
99
+ },
100
+ });
101
+ try {
102
+ run();
103
+ } finally {
104
+ if (original) {
105
+ Object.defineProperty(window, 'sessionStorage', original);
106
+ }
107
+ }
108
+ }
109
+
110
+ describe('ProfileGate', () => {
111
+ it('sends a user with an unfinished profile to its edit form', () => {
112
+ const seen = mountAt('/news', {
113
+ userSession: { token: 'a-token' },
114
+ myProfile: profileState(),
115
+ });
116
+
117
+ expect(seen.path).toBe('/identity-profiles/alice/edit');
118
+ });
119
+
120
+ it('leaves a finished profile where it was going', () => {
121
+ const seen = mountAt('/news', {
122
+ userSession: { token: 'a-token' },
123
+ myProfile: profileState({
124
+ data: {
125
+ '@id': '/@my-profile',
126
+ userid: 'alice',
127
+ profile: PROFILE,
128
+ review_state: 'complete',
129
+ },
130
+ }),
131
+ });
132
+
133
+ expect(seen.path).toBe('/news');
134
+ });
135
+
136
+ it('does not redirect before the answer has arrived', () => {
137
+ const seen = mountAt('/news', {
138
+ userSession: { token: 'a-token' },
139
+ myProfile: { loading: true, loaded: false, error: null },
140
+ });
141
+
142
+ expect(seen.path).toBe('/news');
143
+ });
144
+
145
+ it('does not redirect when the backend answered with an error', () => {
146
+ // A backend that cannot answer must not be able to make the site
147
+ // unreachable. The worst case of letting somebody through is that they
148
+ // fill their profile in later.
149
+ const seen = mountAt('/news', {
150
+ userSession: { token: 'a-token' },
151
+ myProfile: { loading: false, loaded: false, error: { status: 500 } },
152
+ });
153
+
154
+ expect(seen.path).toBe('/news');
155
+ });
156
+
157
+ it('asks for the profile when there is a session', () => {
158
+ // And again on every navigation, which is deliberate: saving the form is
159
+ // a navigation, and a stale answer here is a user held on a profile they
160
+ // have already completed.
161
+ dispatched.length = 0;
162
+
163
+ mountAt('/news', {
164
+ userSession: { token: 'a-token' },
165
+ myProfile: { loading: false, loaded: false, error: null },
166
+ });
167
+
168
+ expect(dispatched.length).toBeGreaterThan(0);
169
+ });
170
+
171
+ it('asks for nothing while anonymous', () => {
172
+ // Every page of a public site would otherwise answer 401 to a request
173
+ // nobody made.
174
+ dispatched.length = 0;
175
+
176
+ mountAt('/news', {
177
+ userSession: {},
178
+ myProfile: { loading: false, loaded: false, error: null },
179
+ });
180
+
181
+ expect(dispatched.length).toBe(0);
182
+ });
183
+
184
+ it('says what it wants before sending them away', () => {
185
+ // A user dropped on an edit form with no explanation cannot tell a
186
+ // requirement from a broken site. The backend reports which fields are
187
+ // missing so the message can name them.
188
+ dispatched.length = 0;
189
+
190
+ withStorage(() => {
191
+ mountAt('/news', {
192
+ userSession: { token: 'a-token' },
193
+ myProfile: profileState({
194
+ data: {
195
+ '@id': '/@my-profile',
196
+ userid: 'alice',
197
+ profile: PROFILE,
198
+ review_state: 'incomplete',
199
+ missing: ['email', 'fullname'],
200
+ },
201
+ }),
202
+ });
203
+ });
204
+
205
+ // Volto's `addMessage` action is flat: {type, id, title, body, level}.
206
+ const message = dispatched.find(
207
+ (action: any) => typeof action?.body === 'string',
208
+ );
209
+ expect(message?.body).toContain('email');
210
+ expect(message?.body).toContain('fullname');
211
+ expect(message?.level).toBe('warning');
212
+ });
213
+
214
+ it('returns the user to where they were going once it is complete', () => {
215
+ // The bug Érico hit: held mid-way through signing in to another site,
216
+ // he completed his profile and was left on the identity provider with
217
+ // no way onward.
218
+ //
219
+ // With a real navigation, and the query string intact. The destination
220
+ // is `@@oauth-authorize`, a backend view: this test used to assert the
221
+ // router had been given it, which renders Volto's 404 at exactly that
222
+ // URL and drops the authorization request's parameters on the way.
223
+ withLocation(() => {
224
+ withStorage(() => {
225
+ rememberReturn('/@@oauth-authorize?client_id=x');
226
+
227
+ const seen = mountAt('/news', {
228
+ userSession: { token: 'a-token' },
229
+ myProfile: profileState({
230
+ data: {
231
+ '@id': '/@my-profile',
232
+ userid: 'alice',
233
+ profile: PROFILE,
234
+ review_state: 'complete',
235
+ missing: [],
236
+ },
237
+ }),
238
+ });
239
+
240
+ expect(seen.path).toBe('/news');
241
+ expect(window.location.href).toBe('/@@oauth-authorize?client_id=x');
242
+ });
243
+ });
244
+ });
245
+
246
+ it('routes a return the app owns, rather than reloading', () => {
247
+ // The other half: a real navigation is for the backend's addresses, and
248
+ // paying for one to reach a route already running is the waste this
249
+ // helper exists to avoid.
250
+ withLocation(() => {
251
+ withStorage(() => {
252
+ rememberReturn('/news/an-article');
253
+
254
+ const seen = mountAt('/news', {
255
+ userSession: { token: 'a-token' },
256
+ myProfile: profileState({
257
+ data: {
258
+ '@id': '/@my-profile',
259
+ userid: 'alice',
260
+ profile: PROFILE,
261
+ review_state: 'complete',
262
+ missing: [],
263
+ },
264
+ }),
265
+ });
266
+
267
+ expect(seen.path).toBe('/news/an-article');
268
+ expect(window.location.href).toBe('');
269
+ });
270
+ });
271
+ });
272
+
273
+ it('does not return anybody who was never held', () => {
274
+ withStorage(() => {
275
+ const seen = mountAt('/news', {
276
+ userSession: { token: 'a-token' },
277
+ myProfile: profileState({
278
+ data: {
279
+ '@id': '/@my-profile',
280
+ userid: 'alice',
281
+ profile: PROFILE,
282
+ review_state: 'complete',
283
+ missing: [],
284
+ },
285
+ }),
286
+ });
287
+
288
+ expect(seen.path).toBe('/news');
289
+ });
290
+ });
291
+
292
+ it('renders nothing', () => {
293
+ const { container } = render(
294
+ <Provider
295
+ store={
296
+ store({
297
+ userSession: { token: 'a-token' },
298
+ myProfile: profileState(),
299
+ }) as any
300
+ }
301
+ >
302
+ <IntlProvider locale="en">
303
+ <MemoryRouter initialEntries={['/identity-profiles/alice/edit']}>
304
+ <ProfileGate />
305
+ </MemoryRouter>
306
+ </IntlProvider>
307
+ </Provider>,
308
+ );
309
+
310
+ expect(container.innerHTML).toBe('');
311
+ });
312
+ });
313
+
314
+ describe('the remembered destination', () => {
315
+ // Volto's test environment provides a `sessionStorage` that accepts writes
316
+ // and returns nothing, so a round trip needs a real one. Installed here
317
+ // rather than globally: the point of the other two tests is what the helper
318
+ // does when storage does *not* work.
319
+ it('comes back once and only once', () => {
320
+ // Twice would send somebody back to a page they had already left.
321
+ withStorage(() => {
322
+ rememberReturn('/news');
323
+
324
+ expect(takeReturn()).toBe('/news');
325
+ expect(takeReturn()).toBe(null);
326
+ });
327
+ });
328
+
329
+ it('ignores an empty destination', () => {
330
+ withStorage(() => {
331
+ rememberReturn('');
332
+
333
+ expect(takeReturn()).toBe(null);
334
+ });
335
+ });
336
+
337
+ it('is nothing when none was remembered', () => {
338
+ withStorage(() => {
339
+ expect(takeReturn()).toBe(null);
340
+ });
341
+ });
342
+
343
+ it('survives storage refusing to work', () => {
344
+ // Private windows throw outright on sessionStorage in some browsers.
345
+ // Losing the return is a worse journey; throwing is a blank page.
346
+ const original = Object.getOwnPropertyDescriptor(window, 'sessionStorage');
347
+ Object.defineProperty(window, 'sessionStorage', {
348
+ configurable: true,
349
+ value: {
350
+ getItem: () => {
351
+ throw new Error('nope');
352
+ },
353
+ setItem: () => {
354
+ throw new Error('nope');
355
+ },
356
+ removeItem: () => {
357
+ throw new Error('nope');
358
+ },
359
+ },
360
+ });
361
+ try {
362
+ expect(() => rememberReturn('/news')).not.toThrow();
363
+ expect(takeReturn()).toBe(null);
364
+ } finally {
365
+ if (original) {
366
+ Object.defineProperty(window, 'sessionStorage', original);
367
+ }
368
+ }
369
+ });
370
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Holding a signed-in user on their profile until it is complete.
3
+ *
4
+ * The backend has a gate of its own, and it deliberately lets
5
+ * `plone.restapi` requests through: Volto fetches the edit form over the API,
6
+ * so gating those would break the page the user is being sent to. Every
7
+ * navigation in this app is such a request, which means the backend gate
8
+ * never fires for a Volto site and this component is where the same rule
9
+ * lives.
10
+ *
11
+ * Wire it into `appExtras` so that it is mounted on every route:
12
+ *
13
+ * ```ts
14
+ * config.settings.appExtras = [
15
+ * ...config.settings.appExtras,
16
+ * { match: '', component: ProfileGate },
17
+ * ];
18
+ * ```
19
+ *
20
+ * It does three things, and the second and third exist because the first one
21
+ * on its own was a trap in the demo.
22
+ *
23
+ * 1. **Redirect**, while the profile is incomplete.
24
+ * 2. **Say why.** A user dropped on an edit form with no explanation cannot
25
+ * tell a requirement from a broken site. The backend reports which fields
26
+ * are missing, so the message names them.
27
+ * 3. **Come back.** The interruption happens mid-journey — in the demo, in
28
+ * the middle of signing in to *another* site through this one — and a user
29
+ * who fills the form in was left on their profile with no way onward. The
30
+ * destination is remembered before redirecting and restored the moment the
31
+ * profile stops being incomplete.
32
+ *
33
+ * The destination is not always this app's to remember. When the backend's
34
+ * authorization endpoint pauses a federated sign-in at this form, it hands
35
+ * the request to resume over as `return_url`, and that target is a backend
36
+ * view rather than a route — so it is taken into the same memory on arrival
37
+ * and resumed with a real navigation.
38
+ *
39
+ * The decision itself lives in `helpers/profileGate`, where it is tested
40
+ * without a store.
41
+ * @module components/ProfileGate/ProfileGate
42
+ */
43
+ import React, { useEffect, useRef } from 'react';
44
+ import { useDispatch, useSelector } from 'react-redux';
45
+ import { useHistory, useLocation } from 'react-router-dom';
46
+ import { defineMessages, useIntl } from 'react-intl';
47
+ import { addMessage } from '@plone/volto/actions';
48
+
49
+ import { getMyProfile } from '../../actions';
50
+ import {
51
+ gateTarget,
52
+ handedOverReturn,
53
+ rememberReturn,
54
+ takeReturn,
55
+ } from '../../helpers/profileGate';
56
+ import { goTo } from '../../helpers/navigate';
57
+
58
+ const messages = defineMessages({
59
+ title: {
60
+ id: 'Complete your profile',
61
+ defaultMessage: 'Complete your profile',
62
+ },
63
+ body: {
64
+ id: 'Your profile needs a few more details before you can continue.',
65
+ defaultMessage:
66
+ 'Your profile needs a few more details before you can continue.',
67
+ },
68
+ bodyWithFields: {
69
+ id: 'Please fill in {fields} before you can continue.',
70
+ defaultMessage: 'Please fill in {fields} before you can continue.',
71
+ },
72
+ });
73
+
74
+ interface ProfileGateProps {
75
+ /** Backend base URL, when it differs from the frontend's. */
76
+ apiPath?: string;
77
+ }
78
+
79
+ const ProfileGate: React.FC<ProfileGateProps> = ({ apiPath = '' }) => {
80
+ const dispatch = useDispatch();
81
+ const history = useHistory();
82
+ const location = useLocation();
83
+ const intl = useIntl();
84
+ const asked = useRef(false);
85
+ const explained = useRef(false);
86
+
87
+ const token = useSelector((state: any) => state.userSession?.token);
88
+ const profile = useSelector((state: any) => state.myProfile);
89
+
90
+ useEffect(() => {
91
+ // Anonymous users have no profile to be held for, and asking would answer
92
+ // 401 on every page of a public site.
93
+ if (!token) {
94
+ return;
95
+ }
96
+ // Re-asked on every navigation, not once: saving the form is a navigation,
97
+ // and a stale answer here is a user held on a profile they have already
98
+ // completed.
99
+ asked.current = true;
100
+ dispatch(getMyProfile());
101
+ }, [dispatch, token, location.pathname]);
102
+
103
+ useEffect(() => {
104
+ if (!token || !profile?.loaded || profile?.error) {
105
+ // Not yet, or not at all. A backend that cannot answer must not be able
106
+ // to make the site unreachable: the worst case of letting somebody
107
+ // through is that they fill their profile in later.
108
+ return;
109
+ }
110
+
111
+ // Whether the *profile* is unfinished, which is not the same question as
112
+ // whether this page would be redirected. Keying the return on `gateTarget`
113
+ // instead is an infinite loop: the gate sends the user to their profile,
114
+ // and being on their profile makes `gateTarget` answer null, which reads
115
+ // as "they finished" and sends them straight back.
116
+ // A destination handed over by the backend. The authorization endpoint
117
+ // pauses its own request at this form and passes it as `return_url`;
118
+ // taking it into the same memory means one way back rather than two.
119
+ const handedOver = handedOverReturn(location.search);
120
+ if (handedOver) {
121
+ rememberReturn(handedOver);
122
+ }
123
+
124
+ const held =
125
+ !!profile.data?.profile && profile.data.review_state === 'incomplete';
126
+
127
+ if (!held) {
128
+ // Finished, or never unfinished. If they were held earlier, this is the
129
+ // moment they completed it, so send them on to where they were going.
130
+ const back = takeReturn();
131
+ if (back && back !== location.pathname) {
132
+ goTo(back, history.replace);
133
+ }
134
+ return;
135
+ }
136
+
137
+ const target = gateTarget(profile.data, location.pathname, apiPath);
138
+
139
+ if (target && target !== location.pathname) {
140
+ rememberReturn(`${location.pathname}${location.search}`);
141
+ if (!explained.current) {
142
+ explained.current = true;
143
+ const missing = profile.data?.missing ?? [];
144
+ dispatch(
145
+ addMessage(
146
+ intl.formatMessage(messages.title),
147
+ missing.length
148
+ ? intl.formatMessage(messages.bodyWithFields, {
149
+ fields: missing.join(', '),
150
+ })
151
+ : intl.formatMessage(messages.body),
152
+ 'warning',
153
+ ),
154
+ );
155
+ }
156
+ history.replace(target);
157
+ }
158
+ }, [
159
+ profile,
160
+ location.pathname,
161
+ location.search,
162
+ history,
163
+ apiPath,
164
+ dispatch,
165
+ intl,
166
+ token,
167
+ ]);
168
+
169
+ return null;
170
+ };
171
+
172
+ export default ProfileGate;
@@ -0,0 +1,111 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import React from 'react';
3
+
4
+ import { PluggablesProvider } from '@plone/volto/components/manage/Pluggable';
5
+ import PersonalTools from './PersonalTools';
6
+ import { withToolbar } from '../../storybook/withUserMenu';
7
+ import IdentitiesMenuItem from '../UserMenu/IdentitiesMenuItem';
8
+ import ProfileMenuItem from '../UserMenu/ProfileMenuItem';
9
+ import {
10
+ PersonalInformationMenuItem,
11
+ PreferencesMenuItem,
12
+ SiteSetupMenuItem,
13
+ } from '../UserMenu/UserMenuPlugs';
14
+ import { LOADED, USER, withStore } from '../../stories/fixtures';
15
+
16
+ /**
17
+ * The shadowed personal-tools menu.
18
+ *
19
+ * Worth a story even though it is Volto's component: it is this package's
20
+ * code now, and these stories are where the entries are seen *in place*
21
+ * rather than each on its own. Every entry is a plug, so a story that mounts
22
+ * none of them renders an empty menu -- which is itself worth seeing, since
23
+ * that is what a site removing them all would get.
24
+ *
25
+ * The menu is normally sized to the toolbar it slides over, which is not
26
+ * here -- `theToolbar` is an empty ref, so it takes its natural width.
27
+ */
28
+ const meta: Meta<typeof PersonalTools> = {
29
+ title: 'Identity/UserMenu/PersonalTools',
30
+ component: PersonalTools,
31
+ // It renders `.personal-tools.pastanaga-menu` itself, but every rule for
32
+ // that class is nested under `#toolbar` in `toolbar.less` -- so without
33
+ // this ancestor the menu came out as an unstyled column of links.
34
+ decorators: [withToolbar],
35
+ parameters: { fullBleed: true },
36
+ args: {
37
+ loadComponent: () => {},
38
+ unloadComponent: () => {},
39
+ theToolbar: { current: null },
40
+ },
41
+ };
42
+ export default meta;
43
+
44
+ type Story = StoryObj<typeof PersonalTools>;
45
+
46
+ const withState = (user: Record<string, unknown>, actions: unknown[] = []) =>
47
+ withStore({
48
+ userProfile: { ...LOADED, data: { ...USER, ...user } },
49
+ actions: { actions: { user: actions } },
50
+ });
51
+
52
+ /** Everything the add-on registers, which is the whole menu. */
53
+ const Menu = (args: React.ComponentProps<typeof PersonalTools>) => (
54
+ <PluggablesProvider>
55
+ <PersonalInformationMenuItem />
56
+ <PreferencesMenuItem />
57
+ <SiteSetupMenuItem />
58
+ <IdentitiesMenuItem />
59
+ <ProfileMenuItem />
60
+ <PersonalTools {...args} />
61
+ </PluggablesProvider>
62
+ );
63
+
64
+ /** An ordinary member: no Site Setup, and "My profile" in the first slot. */
65
+ export const Member: Story = {
66
+ render: (args) => <Menu {...args} />,
67
+ decorators: [withState({})],
68
+ };
69
+
70
+ /** A manager, who also gets Site Setup — last, because it is about the site. */
71
+ export const Manager: Story = {
72
+ render: (args) => <Menu {...args} />,
73
+ decorators: [withState({}, [{ id: 'plone_setup' }])],
74
+ };
75
+
76
+ /**
77
+ * A user with no Profile: Volto's own Profile link takes the slot back,
78
+ * leading to the member form at `/personal-information`.
79
+ *
80
+ * "Sign-in methods" stays, because identities are core rather than part of
81
+ * the optional layer.
82
+ */
83
+ export const WithoutTheProfileLayer: Story = {
84
+ render: (args) => <Menu {...args} />,
85
+ decorators: [withState({ profile_url: null })],
86
+ };
87
+
88
+ /**
89
+ * A user with no name yet. The header shows the userid rather than nothing:
90
+ * an empty header reads as a broken menu.
91
+ */
92
+ export const NameNotLoadedYet: Story = {
93
+ render: (args) => <Menu {...args} />,
94
+ decorators: [withState({ fullname: null, username: null })],
95
+ };
96
+
97
+ /**
98
+ * The component on its own, with nothing plugged in.
99
+ *
100
+ * Not a state a running site is in -- the add-on registers five entries --
101
+ * but it is what this component actually contributes: the header, the width,
102
+ * and an empty list.
103
+ */
104
+ export const NothingPluggedIn: Story = {
105
+ render: (args) => (
106
+ <PluggablesProvider>
107
+ <PersonalTools {...args} />
108
+ </PluggablesProvider>
109
+ ),
110
+ decorators: [withState({})],
111
+ };