@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.
- package/.changelog.draft +261 -0
- package/.release-it.json +34 -0
- package/CHANGELOG.md +269 -0
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/babel.config.js +17 -0
- package/locales/de/LC_MESSAGES/volto.po +1162 -0
- package/locales/en/LC_MESSAGES/volto.po +1162 -0
- package/locales/es/LC_MESSAGES/volto.po +1169 -0
- package/locales/pt_BR/LC_MESSAGES/volto.po +1167 -0
- package/locales/volto.pot +1164 -0
- package/news/.changelog_template.jinja +15 -0
- package/news/.gitkeep +0 -0
- package/package.json +66 -0
- package/public/.gitkeep +0 -0
- package/src/actions/actions.test.ts +226 -0
- package/src/actions/index.ts +530 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/Applications/Applications.tsx +169 -0
- package/src/components/Applications/ApplicationsPanel.scss +59 -0
- package/src/components/Applications/ApplicationsPanel.stories.tsx +82 -0
- package/src/components/Applications/ApplicationsPanel.test.tsx +235 -0
- package/src/components/Applications/ApplicationsPanel.tsx +342 -0
- package/src/components/Callback/Callback.scss +17 -0
- package/src/components/Callback/Callback.stories.tsx +43 -0
- package/src/components/Callback/Callback.test.tsx +262 -0
- package/src/components/Callback/Callback.tsx +170 -0
- package/src/components/Consent/Consent.test.tsx +75 -0
- package/src/components/Consent/Consent.tsx +98 -0
- package/src/components/Consent/ConsentPanel.scss +37 -0
- package/src/components/Consent/ConsentPanel.stories.tsx +80 -0
- package/src/components/Consent/ConsentPanel.test.tsx +138 -0
- package/src/components/Consent/ConsentPanel.tsx +162 -0
- package/src/components/ControlPanel/ClientsControlPanel.stories.tsx +38 -0
- package/src/components/ControlPanel/ClientsControlPanel.tsx +321 -0
- package/src/components/ControlPanel/ClientsPanel.scss +21 -0
- package/src/components/ControlPanel/ClientsPanel.stories.tsx +71 -0
- package/src/components/ControlPanel/ClientsPanel.test.tsx +267 -0
- package/src/components/ControlPanel/ClientsPanel.tsx +386 -0
- package/src/components/ControlPanel/ConfirmModal.stories.tsx +35 -0
- package/src/components/ControlPanel/ConfirmModal.test.tsx +66 -0
- package/src/components/ControlPanel/ConfirmModal.tsx +68 -0
- package/src/components/ControlPanel/ProviderForm.stories.tsx +107 -0
- package/src/components/ControlPanel/ProvidersControlPanel.scss +26 -0
- package/src/components/ControlPanel/ProvidersControlPanel.stories.tsx +88 -0
- package/src/components/ControlPanel/ProvidersControlPanel.tsx +600 -0
- package/src/components/ControlPanel/RenderUsers.stories.tsx +112 -0
- package/src/components/ControlPanel/RenderUsers.test.tsx +145 -0
- package/src/components/ControlPanel/RenderUsers.tsx +263 -0
- package/src/components/ControlPanel/SecretReveal.scss +32 -0
- package/src/components/ControlPanel/SecretReveal.stories.tsx +33 -0
- package/src/components/ControlPanel/SecretReveal.test.tsx +101 -0
- package/src/components/ControlPanel/SecretReveal.tsx +105 -0
- package/src/components/ControlPanel/UserAccount.stories.tsx +69 -0
- package/src/components/ControlPanel/UserAccount.test.tsx +117 -0
- package/src/components/ControlPanel/UserAccount.tsx +125 -0
- package/src/components/ControlPanel/UserAccountPanel.scss +38 -0
- package/src/components/ControlPanel/UserAccountPanel.stories.tsx +77 -0
- package/src/components/ControlPanel/UserAccountPanel.test.tsx +173 -0
- package/src/components/ControlPanel/UserAccountPanel.tsx +273 -0
- package/src/components/FirstLogin/FirstLogin.scss +12 -0
- package/src/components/FirstLogin/FirstLogin.stories.tsx +50 -0
- package/src/components/FirstLogin/FirstLogin.tsx +94 -0
- package/src/components/Identities/Identities.stories.tsx +51 -0
- package/src/components/Identities/Identities.test.tsx +134 -0
- package/src/components/Identities/Identities.tsx +217 -0
- package/src/components/Identities/IdentitiesList.scss +49 -0
- package/src/components/Identities/IdentitiesList.stories.tsx +71 -0
- package/src/components/Identities/IdentitiesList.test.tsx +218 -0
- package/src/components/Identities/IdentitiesList.tsx +207 -0
- package/src/components/Identities/ProfileEmails.scss +62 -0
- package/src/components/Identities/ProfileEmails.stories.tsx +75 -0
- package/src/components/Identities/ProfileEmails.test.tsx +132 -0
- package/src/components/Identities/ProfileEmails.tsx +217 -0
- package/src/components/Login/Login.stories.tsx +63 -0
- package/src/components/Login/Login.test.tsx +189 -0
- package/src/components/Login/Login.tsx +163 -0
- package/src/components/Login/LoginForm.scss +39 -0
- package/src/components/Login/LoginForm.stories.tsx +112 -0
- package/src/components/Login/LoginForm.test.tsx +483 -0
- package/src/components/Login/LoginForm.tsx +321 -0
- package/src/components/Login/LoginOverlay.scss +44 -0
- package/src/components/Login/LoginOverlay.stories.tsx +73 -0
- package/src/components/Login/LoginOverlay.test.tsx +98 -0
- package/src/components/Login/LoginOverlay.tsx +107 -0
- package/src/components/Login/LoginPanel.scss +50 -0
- package/src/components/Login/LoginPanel.stories.tsx +40 -0
- package/src/components/Login/LoginPanel.tsx +60 -0
- package/src/components/Login/MagicLinkForm.scss +25 -0
- package/src/components/Login/MagicLinkForm.stories.tsx +27 -0
- package/src/components/Login/MagicLinkForm.tsx +133 -0
- package/src/components/Login/PasswordForm.scss +82 -0
- package/src/components/Login/PasswordForm.stories.tsx +26 -0
- package/src/components/Login/PasswordForm.tsx +159 -0
- package/src/components/Login/ProviderButton.scss +103 -0
- package/src/components/Login/ProviderButton.stories.tsx +74 -0
- package/src/components/Login/ProviderButton.test.tsx +99 -0
- package/src/components/Login/ProviderButton.tsx +97 -0
- package/src/components/ProfileGate/ProfileGate.stories.tsx +118 -0
- package/src/components/ProfileGate/ProfileGate.test.tsx +370 -0
- package/src/components/ProfileGate/ProfileGate.tsx +172 -0
- package/src/components/Toolbar/PersonalTools.stories.tsx +111 -0
- package/src/components/Toolbar/PersonalTools.test.tsx +165 -0
- package/src/components/Toolbar/PersonalTools.tsx +177 -0
- package/src/components/Toolbar/Toolbar.jsx +766 -0
- package/src/components/UserMenu/ApplicationsMenuItem.stories.tsx +62 -0
- package/src/components/UserMenu/ApplicationsMenuItem.test.tsx +109 -0
- package/src/components/UserMenu/ApplicationsMenuItem.tsx +82 -0
- package/src/components/UserMenu/IdentitiesMenuItem.stories.tsx +35 -0
- package/src/components/UserMenu/IdentitiesMenuItem.test.tsx +103 -0
- package/src/components/UserMenu/IdentitiesMenuItem.tsx +34 -0
- package/src/components/UserMenu/MenuItem.stories.tsx +32 -0
- package/src/components/UserMenu/MenuItem.tsx +48 -0
- package/src/components/UserMenu/ProfileMenuItem.stories.tsx +76 -0
- package/src/components/UserMenu/ProfileMenuItem.test.tsx +90 -0
- package/src/components/UserMenu/ProfileMenuItem.tsx +60 -0
- package/src/components/UserMenu/UserAvatar.scss +23 -0
- package/src/components/UserMenu/UserAvatar.stories.tsx +121 -0
- package/src/components/UserMenu/UserAvatar.test.tsx +120 -0
- package/src/components/UserMenu/UserAvatar.tsx +119 -0
- package/src/components/UserMenu/UserMenuPlugs.stories.tsx +101 -0
- package/src/components/UserMenu/UserMenuPlugs.test.tsx +202 -0
- package/src/components/UserMenu/UserMenuPlugs.tsx +142 -0
- package/src/components/UserMenu/UserProfileLoader.test.tsx +211 -0
- package/src/components/UserMenu/UserProfileLoader.tsx +54 -0
- package/src/components/Views/GroupView.scss +36 -0
- package/src/components/Views/GroupView.stories.tsx +98 -0
- package/src/components/Views/GroupView.test.tsx +137 -0
- package/src/components/Views/GroupView.tsx +178 -0
- package/src/components/Views/ProfileView.scss +27 -0
- package/src/components/Views/ProfileView.stories.tsx +34 -0
- package/src/components/Views/ProfileView.test.tsx +82 -0
- package/src/components/Views/ProfileView.tsx +100 -0
- package/src/components/Widgets/ProviderIconWidget.scss +21 -0
- package/src/components/Widgets/ProviderIconWidget.stories.tsx +30 -0
- package/src/components/Widgets/ProviderIconWidget.test.tsx +55 -0
- package/src/components/Widgets/ProviderIconWidget.tsx +143 -0
- package/src/config/menu.ts +35 -0
- package/src/config/reducers.ts +7 -0
- package/src/config/routes.test.ts +177 -0
- package/src/config/routes.ts +143 -0
- package/src/config/settings.test.ts +34 -0
- package/src/config/settings.ts +72 -0
- package/src/config/views.ts +30 -0
- package/src/config/widgets.ts +25 -0
- package/src/constants/ActionTypes.ts +42 -0
- package/src/constants/vocabularies.ts +20 -0
- package/src/customizations/volto/components/manage/Controlpanels/Users/RenderUsers.tsx +23 -0
- package/src/customizations/volto/components/manage/Toolbar/PersonalTools.tsx +21 -0
- package/src/customizations/volto/components/manage/Toolbar/Toolbar.jsx +21 -0
- package/src/helpers/avatar.test.ts +79 -0
- package/src/helpers/avatar.ts +85 -0
- package/src/helpers/callback.test.ts +60 -0
- package/src/helpers/callback.ts +71 -0
- package/src/helpers/clientSchema.test.ts +182 -0
- package/src/helpers/clientSchema.ts +181 -0
- package/src/helpers/firstLogin.test.ts +90 -0
- package/src/helpers/firstLogin.ts +76 -0
- package/src/helpers/groupmap.test.ts +81 -0
- package/src/helpers/groupmap.ts +50 -0
- package/src/helpers/identities.ts +22 -0
- package/src/helpers/navigate.test.ts +132 -0
- package/src/helpers/navigate.ts +126 -0
- package/src/helpers/profileGate.test.ts +150 -0
- package/src/helpers/profileGate.ts +191 -0
- package/src/helpers/profileSource.ts +41 -0
- package/src/helpers/propertymap.test.ts +71 -0
- package/src/helpers/propertymap.ts +43 -0
- package/src/helpers/providerSchema.test.ts +410 -0
- package/src/helpers/providerSchema.ts +362 -0
- package/src/helpers/returnUrl.test.ts +42 -0
- package/src/helpers/returnUrl.ts +41 -0
- package/src/helpers/rowmap.ts +110 -0
- package/src/helpers/showPloneLogin.test.ts +78 -0
- package/src/helpers/showPloneLogin.ts +55 -0
- package/src/helpers/token.test.ts +62 -0
- package/src/helpers/token.ts +71 -0
- package/src/index.ts +22 -0
- package/src/reducers/index.ts +369 -0
- package/src/reducers/reducers.test.ts +369 -0
- package/src/stories/fixtures.tsx +640 -0
- package/src/storybook/withPage.test.tsx +90 -0
- package/src/storybook/withPage.tsx +110 -0
- package/src/storybook/withUserMenu.test.tsx +78 -0
- package/src/storybook/withUserMenu.tsx +136 -0
- package/src/styles.css +262 -0
- package/src/testing.tsx +86 -0
- package/src/types.ts +441 -0
- package/towncrier.toml +38 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|