@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,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
|
+
};
|