@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,132 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { goTo, isBackendView, isInternal, isSameOrigin } from './navigate';
4
+
5
+ describe('isInternal', () => {
6
+ it('accepts a site-relative path', () => {
7
+ expect(isInternal('/identities')).toBe(true);
8
+ });
9
+
10
+ it('refuses a protocol-relative URL', () => {
11
+ // It starts with a slash and is not local at all, which is exactly the
12
+ // shape a naive check gets wrong.
13
+ expect(isInternal('//evil.example/x')).toBe(false);
14
+ });
15
+
16
+ it('refuses an absolute URL', () => {
17
+ expect(isInternal('https://evil.example/x')).toBe(false);
18
+ });
19
+ });
20
+
21
+ describe('isBackendView', () => {
22
+ it('recognises a view at the root', () => {
23
+ expect(isBackendView('/@@oauth-authorize?response_type=code')).toBe(true);
24
+ });
25
+
26
+ it('recognises a view traversed to further down', () => {
27
+ // Per segment rather than by prefix: a view can hang off any object.
28
+ expect(isBackendView('/profiles/jane/@@download')).toBe(true);
29
+ });
30
+
31
+ it('recognises a traversal namespace', () => {
32
+ expect(isBackendView('/++api++/@profile')).toBe(true);
33
+ expect(isBackendView('/++resource++plone-logo.svg')).toBe(true);
34
+ });
35
+
36
+ it('recognises the user folder', () => {
37
+ expect(
38
+ isBackendView('/acl_users/credentials_cookie_auth/require_login'),
39
+ ).toBe(true);
40
+ });
41
+
42
+ it('leaves an ordinary route alone', () => {
43
+ expect(isBackendView('/identities')).toBe(false);
44
+ expect(isBackendView('/profiles/jane/edit')).toBe(false);
45
+ });
46
+
47
+ it('is not fooled by a query string mentioning one', () => {
48
+ // The decision is about where the browser is going, not about what it
49
+ // carries: this target is the login route and the router owns it.
50
+ expect(isBackendView('/login?came_from=%2F%40%40oauth-authorize')).toBe(
51
+ false,
52
+ );
53
+ });
54
+ });
55
+
56
+ describe('isSameOrigin', () => {
57
+ it('accepts this site', () => {
58
+ expect(isSameOrigin(`${window.location.origin}/x`)).toBe(true);
59
+ });
60
+
61
+ it('refuses another', () => {
62
+ expect(isSameOrigin('https://evil.example/x')).toBe(false);
63
+ });
64
+
65
+ it('refuses something that is not a URL at all', () => {
66
+ expect(isSameOrigin('http://[')).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe('goTo', () => {
71
+ it('routes an internal path rather than reloading', () => {
72
+ // The whole point: a page load throws the bundle and the store away to
73
+ // reach a route this application is already running.
74
+ const push = vi.fn();
75
+
76
+ goTo('/identities', push);
77
+
78
+ expect(push).toHaveBeenCalledWith('/identities');
79
+ });
80
+
81
+ it('sends a foreign target nowhere without permission', () => {
82
+ // `came_from` arrives in a query string. Handing an absolute one to the
83
+ // browser is an open redirect, so the caller's fallback is used instead.
84
+ const push = vi.fn();
85
+
86
+ goTo('https://evil.example/x', push);
87
+
88
+ expect(push).toHaveBeenCalledWith('/');
89
+ });
90
+
91
+ it('allows a foreign target when the caller says it is external', () => {
92
+ // A provider's authorize URL, which is the case this exists for.
93
+ const push = vi.fn();
94
+ const assign = vi.spyOn(window, 'location', 'get');
95
+
96
+ goTo('https://github.com/login/oauth', push, { external: true });
97
+
98
+ expect(push).not.toHaveBeenCalled();
99
+ assign.mockRestore();
100
+ });
101
+
102
+ it('does not route a site-relative backend view', () => {
103
+ // The bug this predicate exists for. Plone's `require_login` hands
104
+ // `came_from` back as `/@@oauth-authorize?…`, the router took it because
105
+ // it starts with a slash, and Volto asked plone.restapi for the content
106
+ // at that path -- dropping the authorization request's query string,
107
+ // getting a 400, and rendering its own 404 at exactly that URL.
108
+ const push = vi.fn();
109
+
110
+ goTo('/@@oauth-authorize?response_type=code&client_id=demo-rp', push);
111
+
112
+ expect(push).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it('does not route a backend view given as an absolute URL', () => {
116
+ // The shape `handedOverReturn` produces: the backend hands the paused
117
+ // authorization request over as a full URL on this origin.
118
+ const push = vi.fn();
119
+
120
+ goTo(`${window.location.origin}/@@oauth-authorize?x=1`, push);
121
+
122
+ expect(push).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('does nothing with an empty target', () => {
126
+ const push = vi.fn();
127
+
128
+ goTo('', push);
129
+
130
+ expect(push).not.toHaveBeenCalled();
131
+ });
132
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Going somewhere, without throwing the application away to do it.
3
+ *
4
+ * `window.location.href` is a page load: the bundle is fetched again, the
5
+ * store is rebuilt from nothing, and every route that was already rendered is
6
+ * rendered a second time. For an address this app owns that is pure waste, and
7
+ * it is what six places in this add-on were doing (Érico, 2026-08-29).
8
+ *
9
+ * It is also the *only* option for the addresses this app does not own — a
10
+ * provider's authorize URL, `@@oauth-authorize` on the backend — so the
11
+ * decision cannot be "never use it". It is a question about the target, asked
12
+ * once, here.
13
+ *
14
+ * A second reason to ask it in one place: `came_from` and `return_url` reach
15
+ * this app from a query string, and handing an absolute one to the browser is
16
+ * an open redirect. Everything site-relative and routable goes through the
17
+ * router; everything else is checked against the current origin first.
18
+ * @module helpers/navigate
19
+ */
20
+
21
+ /**
22
+ * Whether a target is a path this application can route to itself.
23
+ *
24
+ * `//evil.example` is the case worth naming: it is protocol-relative, so it
25
+ * starts with a slash and is not local at all.
26
+ *
27
+ * @param target Where somebody wants to go.
28
+ * @returns Whether the router can take them.
29
+ */
30
+ export function isInternal(target: string): boolean {
31
+ return target.startsWith('/') && !target.startsWith('//');
32
+ }
33
+
34
+ /**
35
+ * Whether a path on this origin is published by the backend rather than Volto.
36
+ *
37
+ * Same origin is not the same as same application. Zope publishes a whole
38
+ * grammar of traversal namespaces alongside Volto's routes — `@@` for a view,
39
+ * `++api++` / `++resource++` / `++plone++` for a namespace, `acl_users` for
40
+ * PAS — and the router owns none of them.
41
+ *
42
+ * The premise this replaces was "site-relative means a route this app owns",
43
+ * and `came_from` is where it breaks. Plone's `require_login` hands back a
44
+ * *site-relative* `/@@oauth-authorize?…`, so an authorization request paused
45
+ * for sign-in came back through the router: Volto asked plone.restapi for the
46
+ * content at that path, dropped the authorization request's query string on
47
+ * the way, got a 400 and rendered its own 404. The relying party that sent
48
+ * the visitor received neither a code nor an error (Érico, 2026-08-30,
49
+ * running the demo stack).
50
+ *
51
+ * Matched per segment rather than by prefix: a view can be traversed to
52
+ * anywhere, and `/profiles/x/@@something` is as much a backend view as
53
+ * `/@@something` is.
54
+ *
55
+ * @param target A path or URL.
56
+ * @returns Whether reaching it needs a real navigation.
57
+ */
58
+ export function isBackendView(target: string): boolean {
59
+ const path = target.split('?')[0].split('#')[0];
60
+ return path
61
+ .split('/')
62
+ .some(
63
+ (segment) =>
64
+ segment.startsWith('@@') ||
65
+ segment.startsWith('++') ||
66
+ segment === 'acl_users',
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Whether a full page load to this target is safe to perform.
72
+ *
73
+ * Same origin only. A target that arrived in a query string and points
74
+ * somewhere else is an open redirect, and the caller's fallback is a route
75
+ * this app owns.
76
+ *
77
+ * @param target An absolute URL.
78
+ * @returns Whether it stays on this site.
79
+ */
80
+ export function isSameOrigin(target: string): boolean {
81
+ if (typeof window === 'undefined') {
82
+ return false;
83
+ }
84
+ try {
85
+ return (
86
+ new URL(target, window.location.origin).origin === window.location.origin
87
+ );
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Send the browser to a target, by the cheapest route that works.
95
+ *
96
+ * @param target Where to go.
97
+ * @param push The router's push or replace, for the paths it owns.
98
+ * @param options `external` allows a full load to another origin, which is
99
+ * what a provider's authorize URL needs and what a `came_from` must never
100
+ * get.
101
+ */
102
+ export function goTo(
103
+ target: string,
104
+ push: (path: string) => void,
105
+ options: { external?: boolean } = {},
106
+ ): void {
107
+ if (!target) {
108
+ return;
109
+ }
110
+ if (isInternal(target) && !isBackendView(target)) {
111
+ push(target);
112
+ return;
113
+ }
114
+ if (typeof window === 'undefined') {
115
+ return;
116
+ }
117
+ // A site-relative backend path is same-origin by construction, so it needs
118
+ // no permission from the caller — only a real navigation.
119
+ if (options.external || isInternal(target) || isSameOrigin(target)) {
120
+ window.location.href = target;
121
+ return;
122
+ }
123
+ // Somewhere else entirely, arriving from a query string. The caller asked
124
+ // for a site-local navigation and this is not one.
125
+ push('/');
126
+ }
@@ -0,0 +1,150 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { editPath, gateTarget, handedOverReturn } from './profileGate';
4
+ import type { MyProfile } from '../types';
5
+
6
+ const PROFILE_URL = 'http://backend:8080/Plone/identity-profiles/alice';
7
+
8
+ function profile(overrides: Partial<MyProfile> = {}): MyProfile {
9
+ return {
10
+ '@id': 'http://backend:8080/Plone/@my-profile',
11
+ userid: 'alice',
12
+ profile: PROFILE_URL,
13
+ review_state: 'incomplete',
14
+ missing: [],
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ describe('editPath', () => {
20
+ it('is the profile path with /edit on it', () => {
21
+ expect(editPath(PROFILE_URL, 'http://backend:8080/Plone')).toBe(
22
+ '/identity-profiles/alice/edit',
23
+ );
24
+ });
25
+ });
26
+
27
+ describe('gateTarget', () => {
28
+ const api = 'http://backend:8080/Plone';
29
+
30
+ it('sends an incomplete profile to its edit form', () => {
31
+ expect(gateTarget(profile(), '/news', api)).toBe(
32
+ '/identity-profiles/alice/edit',
33
+ );
34
+ });
35
+
36
+ it('lets a complete profile through', () => {
37
+ expect(
38
+ gateTarget(profile({ review_state: 'complete' }), '/news', api),
39
+ ).toBe(null);
40
+ });
41
+
42
+ it('lets a deactivated profile through', () => {
43
+ // Being deactivated is not something the user can fix by filling a form
44
+ // in, and holding them on one would be a loop with no exit at all.
45
+ expect(
46
+ gateTarget(profile({ review_state: 'deactivated' }), '/news', api),
47
+ ).toBe(null);
48
+ });
49
+
50
+ it('lets a user with no profile through', () => {
51
+ expect(gateTarget(profile({ profile: null }), '/news', api)).toBe(null);
52
+ });
53
+
54
+ it('lets a site without the extra through', () => {
55
+ expect(
56
+ gateTarget(profile({ profile: null, review_state: null }), '/news', api),
57
+ ).toBe(null);
58
+ });
59
+
60
+ it('does nothing before the answer has arrived', () => {
61
+ expect(gateTarget(null, '/news', api)).toBe(null);
62
+ expect(gateTarget(undefined, '/news', api)).toBe(null);
63
+ });
64
+
65
+ it('does not gate the edit form it redirects to', () => {
66
+ // The loop this whole function exists to avoid.
67
+ expect(gateTarget(profile(), '/identity-profiles/alice/edit', api)).toBe(
68
+ null,
69
+ );
70
+ });
71
+
72
+ it('does not gate the profile itself', () => {
73
+ expect(gateTarget(profile(), '/identity-profiles/alice', api)).toBe(null);
74
+ });
75
+
76
+ it('does not gate anything beneath the profile', () => {
77
+ // The edit form loads widgets and vocabularies against paths under it,
78
+ // and saving bounces the user to the profile's own view.
79
+ expect(
80
+ gateTarget(profile(), '/identity-profiles/alice/@@images/image', api),
81
+ ).toBe(null);
82
+ });
83
+
84
+ it('does not gate another user profile whose path merely starts the same', () => {
85
+ // `/identity-profiles/alice2` starts with `/identity-profiles/alice`, and
86
+ // a naive prefix test would let somebody sit on a stranger's profile
87
+ // instead of filling in their own.
88
+ expect(gateTarget(profile(), '/identity-profiles/alice2', api)).toBe(
89
+ '/identity-profiles/alice/edit',
90
+ );
91
+ });
92
+
93
+ it.each([
94
+ '/login',
95
+ '/login-identity',
96
+ '/logout',
97
+ '/first-login',
98
+ '/oauth-consent',
99
+ ])('does not gate %s', (path) => {
100
+ expect(gateTarget(profile(), path, api)).toBe(null);
101
+ });
102
+
103
+ it('does not gate a route beneath an exempt one', () => {
104
+ expect(gateTarget(profile(), '/first-login?return_url=/news', api)).toBe(
105
+ '/identity-profiles/alice/edit',
106
+ );
107
+ expect(gateTarget(profile(), '/logout/anything', api)).toBe(null);
108
+ });
109
+
110
+ it('treats an empty path as the front page', () => {
111
+ expect(gateTarget(profile(), '', api)).toBe(
112
+ '/identity-profiles/alice/edit',
113
+ );
114
+ });
115
+ });
116
+
117
+ describe('handedOverReturn', () => {
118
+ // The backend's authorization endpoint pauses its request at the profile
119
+ // form and hands the request to resume over in the query string. Honouring
120
+ // it is a real navigation, so an unchecked value here is an open redirect:
121
+ // a link to somebody's profile carrying a `return_url` would bounce a
122
+ // signed-in user anywhere.
123
+
124
+ it('takes a site-relative path', () => {
125
+ expect(
126
+ handedOverReturn('?identity_resume=%2F%40%40oauth-authorize%3Fx%3D1'),
127
+ ).toBe('/@@oauth-authorize?x=1');
128
+ });
129
+
130
+ it('is nothing when there is none', () => {
131
+ expect(handedOverReturn('')).toBe(null);
132
+ expect(handedOverReturn('?other=1')).toBe(null);
133
+ // Volto's own parameter is not ours, and taking it would fight the
134
+ // edit form for the same navigation.
135
+ expect(handedOverReturn('?return_url=%2Fnews')).toBe(null);
136
+ });
137
+
138
+ it('refuses a protocol-relative target', () => {
139
+ // "//evil.example" is not site-relative, however much it looks it.
140
+ expect(handedOverReturn('?identity_resume=%2F%2Fevil.example%2Fx')).toBe(
141
+ null,
142
+ );
143
+ });
144
+
145
+ it('refuses another origin', () => {
146
+ expect(
147
+ handedOverReturn('?identity_resume=https%3A%2F%2Fevil.example%2Fx'),
148
+ ).toBe(null);
149
+ });
150
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Deciding whether a user may go where they are going.
3
+ *
4
+ * The backend holds a user with an incomplete profile on its edit form, and
5
+ * deliberately lets `plone.restapi` requests through: Volto fetches the edit
6
+ * form itself over the API, so gating those would break the page the user is
7
+ * being sent to. Everything this app navigates to is an API request, which
8
+ * means the backend gate never fires for a Volto site and this is where the
9
+ * same rule lives.
10
+ *
11
+ * Pure and separate from the component for the reason `firstLogin` is: the
12
+ * decision is small, every branch of it is a way to trap somebody in a
13
+ * redirect loop, and each one deserves a test that needs no store.
14
+ * @module helpers/profileGate
15
+ */
16
+
17
+ import { toAppPath } from './firstLogin';
18
+
19
+ import type { MyProfile } from '../types';
20
+
21
+ /**
22
+ * Routes never gated, whatever state the profile is in.
23
+ *
24
+ * Signing in has to be able to finish, and signing out has to be possible for
25
+ * somebody who would rather leave than fill the form in. `/first-login` is
26
+ * here because it is the route that decides where to send them: gating it
27
+ * would replace its answer with this one before it had a chance to give it.
28
+ */
29
+ export const EXEMPT_PATHS = [
30
+ '/login',
31
+ '/login-identity',
32
+ '/logout',
33
+ '/first-login',
34
+ '/oauth-consent',
35
+ ];
36
+
37
+ /**
38
+ * Where the pending destination is kept while the user fills the form in.
39
+ *
40
+ * `sessionStorage` rather than the URL, because the URL does not survive the
41
+ * step that matters: saving the form navigates to the profile's own view and
42
+ * drops the query string with it. Rather than the redux store, because a
43
+ * reload mid-detour would lose that too, and a user who reloads a form they
44
+ * were sent to should still be returned afterwards.
45
+ */
46
+ export const RETURN_KEY = 'volto-identity:profile-return';
47
+
48
+ /**
49
+ * Remember where the user was going before the gate interrupted them.
50
+ *
51
+ * Every access is guarded: `sessionStorage` throws outright in a private
52
+ * window in some browsers, and does not exist at all during server-side
53
+ * rendering. Losing the return is a worse journey; throwing here would be a
54
+ * blank page.
55
+ *
56
+ * @param path Site-relative path to come back to.
57
+ */
58
+ export function rememberReturn(path: string): void {
59
+ if (typeof window === 'undefined' || !path) {
60
+ return;
61
+ }
62
+ try {
63
+ window.sessionStorage.setItem(RETURN_KEY, path);
64
+ } catch {
65
+ // No storage. The user completes their profile and stays there, which is
66
+ // where they would have stayed before any of this existed.
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Query parameter the backend hands a paused request over in.
72
+ *
73
+ * Must not be `return_url`: Volto's edit form claims that name.
74
+ */
75
+ export const RESUME_PARAM = 'identity_resume';
76
+
77
+ /**
78
+ * Read a destination handed over in the query string, if it is safe to use.
79
+ *
80
+ * The backend's authorization endpoint pauses a request at the profile form
81
+ * and passes the request to resume as `identity_resume`. Volto knows nothing
82
+ * about that URL — `@@oauth-authorize` is a backend view, not a route — so it
83
+ * has to be carried across rather than recomputed.
84
+ *
85
+ * The name matters. `return_url` is Volto's own: its edit form reads that
86
+ * parameter and pushes it through the router after a save, and an absolute
87
+ * URL pushed that way is resolved against the current path. Using it handed
88
+ * the user a navigation to `/profiles/<id>/http:/host/@@oauth-authorize` and
89
+ * two 404s before the real redirect caught up.
90
+ *
91
+ * Only same-origin targets survive. Honouring an arbitrary `return_url` with
92
+ * a real navigation is an open redirect, and this one *is* a real navigation:
93
+ * a link to a profile with a `return_url` on it would otherwise be a way to
94
+ * bounce a signed-in user anywhere.
95
+ *
96
+ * @param search The location's query string.
97
+ * @returns The destination, or null.
98
+ */
99
+ export function handedOverReturn(search: string): string | null {
100
+ const requested = new URLSearchParams(search).get(RESUME_PARAM);
101
+ if (!requested) {
102
+ return null;
103
+ }
104
+ if (requested.startsWith('/') && !requested.startsWith('//')) {
105
+ return requested;
106
+ }
107
+ if (typeof window === 'undefined') {
108
+ return null;
109
+ }
110
+ try {
111
+ const url = new URL(requested, window.location.origin);
112
+ return url.origin === window.location.origin ? url.href : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Take the remembered destination, clearing it.
120
+ *
121
+ * Reading and clearing together, so a return can never fire twice: the second
122
+ * navigation would send somebody back to a page they had already left.
123
+ *
124
+ * @returns The remembered path, or null.
125
+ */
126
+ export function takeReturn(): string | null {
127
+ if (typeof window === 'undefined') {
128
+ return null;
129
+ }
130
+ try {
131
+ const value = window.sessionStorage.getItem(RETURN_KEY);
132
+ if (value) {
133
+ window.sessionStorage.removeItem(RETURN_KEY);
134
+ }
135
+ return value || null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The path of a profile's edit form.
143
+ *
144
+ * @param profileUrl The absolute URL the backend reported.
145
+ * @param apiPath The backend's base URL, when it differs from the frontend's.
146
+ * @returns A site-relative path.
147
+ */
148
+ export function editPath(profileUrl: string, apiPath = ''): string {
149
+ return `${toAppPath(profileUrl, apiPath)}/edit`;
150
+ }
151
+
152
+ /**
153
+ * Work out where a user must be sent, if anywhere.
154
+ *
155
+ * Returns `null` for every reason not to gate, which is most of them: no
156
+ * profile, a complete one, an answer that has not arrived, an exempt route,
157
+ * and — the one that matters — being already on the profile the redirect
158
+ * would send them to.
159
+ *
160
+ * That last check covers the whole profile rather than its edit form alone.
161
+ * The form loads widgets and vocabularies against paths beneath it, and a
162
+ * user who saves is bounced to the profile's view; gating either would be a
163
+ * loop that no amount of correct configuration escapes.
164
+ *
165
+ * @param profile The `@my-profile` answer, or null before it has loaded.
166
+ * @param pathname The path the app is on.
167
+ * @param apiPath The backend's base URL, when it differs from the frontend's.
168
+ * @returns The path to redirect to, or null to let the user through.
169
+ */
170
+ export function gateTarget(
171
+ profile: MyProfile | null | undefined,
172
+ pathname: string,
173
+ apiPath = '',
174
+ ): string | null {
175
+ if (!profile?.profile || profile.review_state !== 'incomplete') {
176
+ return null;
177
+ }
178
+ const current = pathname || '/';
179
+ if (
180
+ EXEMPT_PATHS.some(
181
+ (path) => current === path || current.startsWith(`${path}/`),
182
+ )
183
+ ) {
184
+ return null;
185
+ }
186
+ const profilePath = toAppPath(profile.profile, apiPath);
187
+ if (current === profilePath || current.startsWith(`${profilePath}/`)) {
188
+ return null;
189
+ }
190
+ return editPath(profile.profile, apiPath);
191
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Whether a user's fields live in a Profile content object.
3
+ *
4
+ * This asked `source === 'identity_profile'` first, and that was wrong.
5
+ * `source` names whichever PAS plugin *enumerated* the account, and which one
6
+ * that is depends on how the account was made: a user added with a password
7
+ * has a `source_users` row and reports that, while an externally
8
+ * authenticated one has only a Profile and reports `identity_profile`. Both
9
+ * of them have their fields in a Profile, so keying the menu on `source` hid
10
+ * the entry for whole classes of user.
11
+ *
12
+ * The question the menu actually needs is narrower: *is this person's name,
13
+ * email and biography stored in a content object?* Having a Profile is
14
+ * exactly that. The profile PAS plugin sits above `mutable_properties`, so
15
+ * wherever a Profile exists it is what answers for that user's fields — and
16
+ * `profile_url` is how the payload says so.
17
+ *
18
+ * The consequence for the menu is the one that was wanted: for a user first
19
+ * login has not minted a Profile for, or an account that predates the add-on,
20
+ * `profile_url` is null and Volto's own Profile entry keeps its slot,
21
+ * leading to the member form where those users' fields really are.
22
+ * @module helpers/profileSource
23
+ */
24
+
25
+ /**
26
+ * Whether this user's fields are held in a Profile.
27
+ *
28
+ * A value rather than the user it came from, so a caller can select it out
29
+ * of the store on its own. `useSelector` compares what it returns by
30
+ * identity: handed the whole user object it sees a change on every render
31
+ * and re-renders forever.
32
+ *
33
+ * @param profileUrl The user's `profile_url`, when they have a Profile.
34
+ * @returns Whether there is a Profile holding their fields, and therefore
35
+ * one to link to. A menu entry leading nowhere is worse than no entry.
36
+ */
37
+ export function profileHoldsTheFields(
38
+ profileUrl: string | null | undefined,
39
+ ): boolean {
40
+ return Boolean(profileUrl);
41
+ }