@eeacms/volto-clms-theme 1.1.291 → 1.1.293
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.md +14 -0
- package/package.json +4 -2
- package/src/components/CLMSLoginView/AuthomaticLoginPlone.jsx +390 -0
- package/src/components/CLMSLoginView/Login.less +8 -0
- package/src/customizations/volto/helpers/Html/Html.jsx +13 -1
- package/src/customizations/volto/helpers/Html/Readme.md +1 -0
- package/src/customizations/volto/server.jsx +375 -0
- package/src/index.js +18 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
### [1.1.293](https://github.com/eea/volto-clms-theme/compare/1.1.292...1.1.293) - 5 June 2026
|
|
8
|
+
|
|
9
|
+
#### :house: Internal changes
|
|
10
|
+
|
|
11
|
+
- style: Automated code fix [eea-jenkins - [`8c05ecd`](https://github.com/eea/volto-clms-theme/commit/8c05ecd107a922be765a39df0c161e5d407ae053)]
|
|
12
|
+
|
|
13
|
+
#### :hammer_and_wrench: Others
|
|
14
|
+
|
|
15
|
+
- add deps [Dobricean Ioan Dorian - [`1000ea9`](https://github.com/eea/volto-clms-theme/commit/1000ea9261702391c255f833e961b27c6fd9a2d5)]
|
|
16
|
+
- add deps [Dobricean Ioan Dorian - [`199a21f`](https://github.com/eea/volto-clms-theme/commit/199a21f7022ff81337cd8e10c609bbbdf0421351)]
|
|
17
|
+
- Add volto-authomatic dependency to package.json [dobri1408 - [`a9151d6`](https://github.com/eea/volto-clms-theme/commit/a9151d62c369a4225a6e0750b28f25f492b37138)]
|
|
18
|
+
- add entra id [Dobricean Ioan Dorian - [`a167b6a`](https://github.com/eea/volto-clms-theme/commit/a167b6ac92480f9e99c1478d322cfc829c99d238)]
|
|
19
|
+
### [1.1.292](https://github.com/eea/volto-clms-theme/compare/1.1.291...1.1.292) - 2 June 2026
|
|
20
|
+
|
|
7
21
|
### [1.1.291](https://github.com/eea/volto-clms-theme/compare/1.1.290...1.1.291) - 22 May 2026
|
|
8
22
|
|
|
9
23
|
### [1.1.290](https://github.com/eea/volto-clms-theme/compare/1.1.289...1.1.290) - 21 May 2026
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eeacms/volto-clms-theme",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.293",
|
|
4
4
|
"description": "volto-clms-theme: Volto theme for CLMS site",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"author": "CodeSyntax for the European Environment Agency",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"volto-cookie-banner",
|
|
33
33
|
"@eeacms/volto-tableau",
|
|
34
34
|
"@eeacms/volto-embed",
|
|
35
|
-
"@eeacms/volto-globalsearch"
|
|
35
|
+
"@eeacms/volto-globalsearch",
|
|
36
|
+
"@plone-collective/volto-authomatic"
|
|
36
37
|
],
|
|
37
38
|
"resolutions": {
|
|
38
39
|
"@elastic/search-ui": "1.21.2",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"@fortawesome/react-fontawesome": "0.1.14",
|
|
60
61
|
"@ginkgo-bioworks/react-json-schema-form-builder": "2.10.1",
|
|
61
62
|
"@kitconcept/volto-blocks-grid": "7.0.2",
|
|
63
|
+
"@plone-collective/volto-authomatic": "2.0.1",
|
|
62
64
|
"connected-react-router": "6.8.0",
|
|
63
65
|
"d3-array": "^2.12.1",
|
|
64
66
|
"husky": "7.0.4",
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combined Login container - supports both external providers and Plone login.
|
|
3
|
+
* @module components/Login/Login
|
|
4
|
+
*/
|
|
5
|
+
import React, { useEffect, useState } from 'react';
|
|
6
|
+
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
|
|
7
|
+
import { Link, useHistory, useLocation } from 'react-router-dom';
|
|
8
|
+
import {
|
|
9
|
+
Container,
|
|
10
|
+
Button,
|
|
11
|
+
Form,
|
|
12
|
+
Input,
|
|
13
|
+
Segment,
|
|
14
|
+
Grid,
|
|
15
|
+
} from 'semantic-ui-react';
|
|
16
|
+
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|
17
|
+
import qs from 'query-string';
|
|
18
|
+
import { useCookies } from 'react-cookie';
|
|
19
|
+
|
|
20
|
+
import { Helmet } from '@plone/volto/helpers';
|
|
21
|
+
import config from '@plone/volto/registry';
|
|
22
|
+
import { Icon } from '@plone/volto/components';
|
|
23
|
+
import { login, resetLoginRequest } from '@plone/volto/actions';
|
|
24
|
+
import { toast } from 'react-toastify';
|
|
25
|
+
import { Toast } from '@plone/volto/components';
|
|
26
|
+
import aheadSVG from '@plone/volto/icons/ahead.svg';
|
|
27
|
+
import clearSVG from '@plone/volto/icons/clear.svg';
|
|
28
|
+
import './Login.less';
|
|
29
|
+
|
|
30
|
+
// Import authomatic components and actions
|
|
31
|
+
import {
|
|
32
|
+
authomaticRedirect,
|
|
33
|
+
listAuthOptions,
|
|
34
|
+
oidcRedirect,
|
|
35
|
+
} from '@plone-collective/volto-authomatic/actions';
|
|
36
|
+
import AuthProviders from '@plone-collective/volto-authomatic/components/AuthProviders/AuthProviders';
|
|
37
|
+
|
|
38
|
+
const messages = defineMessages({
|
|
39
|
+
login: {
|
|
40
|
+
id: 'Log in',
|
|
41
|
+
defaultMessage: 'Log in',
|
|
42
|
+
},
|
|
43
|
+
loginName: {
|
|
44
|
+
id: 'Login Name',
|
|
45
|
+
defaultMessage: 'Login Name',
|
|
46
|
+
},
|
|
47
|
+
Login: {
|
|
48
|
+
id: 'Login',
|
|
49
|
+
defaultMessage: 'Login',
|
|
50
|
+
},
|
|
51
|
+
password: {
|
|
52
|
+
id: 'Password',
|
|
53
|
+
defaultMessage: 'Password',
|
|
54
|
+
},
|
|
55
|
+
cancel: {
|
|
56
|
+
id: 'Cancel',
|
|
57
|
+
defaultMessage: 'Cancel',
|
|
58
|
+
},
|
|
59
|
+
error: {
|
|
60
|
+
id: 'Error',
|
|
61
|
+
defaultMessage: 'Error',
|
|
62
|
+
},
|
|
63
|
+
loginFailed: {
|
|
64
|
+
id: 'Login Failed',
|
|
65
|
+
defaultMessage: 'Login Failed',
|
|
66
|
+
},
|
|
67
|
+
loginFailedContent: {
|
|
68
|
+
id:
|
|
69
|
+
'Both email address and password are case sensitive, check that caps lock is not enabled.',
|
|
70
|
+
defaultMessage:
|
|
71
|
+
'Both email address and password are case sensitive, check that caps lock is not enabled.',
|
|
72
|
+
},
|
|
73
|
+
register: {
|
|
74
|
+
id: 'Register',
|
|
75
|
+
defaultMessage: 'Register',
|
|
76
|
+
},
|
|
77
|
+
forgotPassword: {
|
|
78
|
+
id: 'box_forgot_password_option',
|
|
79
|
+
defaultMessage: 'Forgot your password?',
|
|
80
|
+
},
|
|
81
|
+
signInWith: {
|
|
82
|
+
id: 'Sign in with EEA Microsoft Entra ID',
|
|
83
|
+
defaultMessage: 'Sign in with EEA Microsoft Entra ID',
|
|
84
|
+
},
|
|
85
|
+
orSignIn: {
|
|
86
|
+
id: 'Or sign in with EEA Entra ID:',
|
|
87
|
+
defaultMessage: 'Or sign in with EEA Entra ID:',
|
|
88
|
+
},
|
|
89
|
+
loading: {
|
|
90
|
+
id: 'Loading',
|
|
91
|
+
defaultMessage: 'Loading',
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get return url function.
|
|
97
|
+
* @function getReturnUrl
|
|
98
|
+
* @param {Object} location Location object.
|
|
99
|
+
* @returns {string} Return url.
|
|
100
|
+
*/
|
|
101
|
+
function getReturnUrl(location) {
|
|
102
|
+
return `${
|
|
103
|
+
qs.parse(location.search).return_url ||
|
|
104
|
+
(location.pathname === '/login'
|
|
105
|
+
? '/'
|
|
106
|
+
: location.pathname.replace('/login', ''))
|
|
107
|
+
}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Combined Login function.
|
|
112
|
+
* @function Login
|
|
113
|
+
* @returns {JSX.Element} Markup of the Login page.
|
|
114
|
+
*/
|
|
115
|
+
function Login({ intl }) {
|
|
116
|
+
const dispatch = useDispatch();
|
|
117
|
+
const history = useHistory();
|
|
118
|
+
const location = useLocation();
|
|
119
|
+
|
|
120
|
+
// Authomatic state
|
|
121
|
+
const [startedOAuth, setStartedOAuth] = useState(false);
|
|
122
|
+
const [startedOIDC, setStartedOIDC] = useState(false);
|
|
123
|
+
const loading = useSelector((state) => state.authOptions.loading);
|
|
124
|
+
const options = useSelector((state) => state.authOptions.options);
|
|
125
|
+
const loginOAuthValues = useSelector((state) => state.authomaticRedirect);
|
|
126
|
+
const loginOIDCValues = useSelector((state) => state.oidcRedirect);
|
|
127
|
+
const [, setCookie] = useCookies();
|
|
128
|
+
|
|
129
|
+
// Plone login state
|
|
130
|
+
const token = useSelector((state) => state.userSession.token, shallowEqual);
|
|
131
|
+
const error = useSelector((state) => state.userSession.login.error);
|
|
132
|
+
const ploneLoading = useSelector((state) => state.userSession.login.loading);
|
|
133
|
+
|
|
134
|
+
const returnUrl =
|
|
135
|
+
qs.parse(location.search).return_url ||
|
|
136
|
+
location.pathname.replace(/\/login\/?$/, '').replace(/\/logout\/?$/, '') ||
|
|
137
|
+
'/';
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
dispatch(listAuthOptions());
|
|
141
|
+
}, [dispatch]);
|
|
142
|
+
|
|
143
|
+
// Handle successful Plone login
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (token) {
|
|
146
|
+
history.push(returnUrl || '/');
|
|
147
|
+
if (toast.isActive('loggedOut')) {
|
|
148
|
+
toast.dismiss('loggedOut');
|
|
149
|
+
}
|
|
150
|
+
if (toast.isActive('loginFailed')) {
|
|
151
|
+
toast.dismiss('loginFailed');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (error) {
|
|
155
|
+
if (toast.isActive('loggedOut')) {
|
|
156
|
+
toast.dismiss('loggedOut');
|
|
157
|
+
}
|
|
158
|
+
if (!toast.isActive('loginFailed')) {
|
|
159
|
+
toast.error(
|
|
160
|
+
<Toast
|
|
161
|
+
error
|
|
162
|
+
title={intl.formatMessage(messages.loginFailed)}
|
|
163
|
+
content={intl.formatMessage(messages.loginFailedContent)}
|
|
164
|
+
/>,
|
|
165
|
+
{ autoClose: false, toastId: 'loginFailed' },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return () => {
|
|
170
|
+
if (toast.isActive('loginFailed')) {
|
|
171
|
+
toast.dismiss('loginFailed');
|
|
172
|
+
dispatch(resetLoginRequest());
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}, [dispatch, token, error, intl, history, returnUrl]);
|
|
176
|
+
|
|
177
|
+
// Handle OAuth redirects
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const next_url = loginOAuthValues.next_url;
|
|
180
|
+
const session = loginOAuthValues.session;
|
|
181
|
+
if (next_url && session && startedOAuth) {
|
|
182
|
+
setStartedOAuth(false);
|
|
183
|
+
// Give time to save state to localstorage
|
|
184
|
+
setTimeout(function () {
|
|
185
|
+
window.location.href = next_url;
|
|
186
|
+
}, 500);
|
|
187
|
+
}
|
|
188
|
+
}, [startedOAuth, loginOAuthValues]);
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
const next_url = loginOIDCValues.next_url;
|
|
192
|
+
if (next_url && startedOIDC) {
|
|
193
|
+
setStartedOIDC(false);
|
|
194
|
+
// Give time to save state to localstorage
|
|
195
|
+
setTimeout(function () {
|
|
196
|
+
window.location.href = next_url;
|
|
197
|
+
}, 500);
|
|
198
|
+
}
|
|
199
|
+
}, [startedOIDC, loginOIDCValues]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (
|
|
203
|
+
options !== undefined &&
|
|
204
|
+
options.length === 1 &&
|
|
205
|
+
options[0].id === 'oidc'
|
|
206
|
+
) {
|
|
207
|
+
setStartedOIDC(true);
|
|
208
|
+
dispatch(oidcRedirect('oidc'));
|
|
209
|
+
}
|
|
210
|
+
}, [options, dispatch]);
|
|
211
|
+
|
|
212
|
+
// Handle provider selection
|
|
213
|
+
const onSelectProvider = (provider) => {
|
|
214
|
+
setStartedOAuth(true);
|
|
215
|
+
setCookie('return_url', getReturnUrl(location), { path: '/' });
|
|
216
|
+
dispatch(authomaticRedirect(provider.id));
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Handle Plone login form submission
|
|
220
|
+
const onLogin = (event) => {
|
|
221
|
+
dispatch(
|
|
222
|
+
login(
|
|
223
|
+
document.getElementsByName('login')[0].value,
|
|
224
|
+
document.getElementsByName('password')[0].value,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Prepare providers for external login
|
|
231
|
+
const validProviders = options
|
|
232
|
+
? options.filter((provider) => provider.id !== 'oidc')
|
|
233
|
+
: [];
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div id="page-login">
|
|
237
|
+
<Helmet title={intl.formatMessage(messages.Login)} />
|
|
238
|
+
<Container text>
|
|
239
|
+
<Segment.Group raised>
|
|
240
|
+
<Segment className="primary">
|
|
241
|
+
<FormattedMessage id="Log In" defaultMessage="Login" />
|
|
242
|
+
</Segment>
|
|
243
|
+
<Segment secondary>
|
|
244
|
+
<FormattedMessage
|
|
245
|
+
id="Sign in to start session"
|
|
246
|
+
defaultMessage="Sign in to start session"
|
|
247
|
+
/>
|
|
248
|
+
</Segment>
|
|
249
|
+
|
|
250
|
+
{/* Plone Login Form */}
|
|
251
|
+
<Segment className="form">
|
|
252
|
+
<Form method="post" onSubmit={onLogin}>
|
|
253
|
+
<Form.Field inline className="help">
|
|
254
|
+
<Grid>
|
|
255
|
+
<Grid.Row stretched>
|
|
256
|
+
<Grid.Column width="4">
|
|
257
|
+
<div className="wrapper">
|
|
258
|
+
<label htmlFor="login">
|
|
259
|
+
<FormattedMessage
|
|
260
|
+
id="Login Name"
|
|
261
|
+
defaultMessage="Login Name"
|
|
262
|
+
/>
|
|
263
|
+
</label>
|
|
264
|
+
</div>
|
|
265
|
+
</Grid.Column>
|
|
266
|
+
<Grid.Column width="8">
|
|
267
|
+
<Input
|
|
268
|
+
id="login"
|
|
269
|
+
name="login"
|
|
270
|
+
placeholder={intl.formatMessage(messages.loginName)}
|
|
271
|
+
/>
|
|
272
|
+
</Grid.Column>
|
|
273
|
+
</Grid.Row>
|
|
274
|
+
</Grid>
|
|
275
|
+
</Form.Field>
|
|
276
|
+
<Form.Field inline className="help">
|
|
277
|
+
<Grid>
|
|
278
|
+
<Grid.Row stretched>
|
|
279
|
+
<Grid.Column stretched width="4">
|
|
280
|
+
<div className="wrapper">
|
|
281
|
+
<label htmlFor="password">
|
|
282
|
+
<FormattedMessage
|
|
283
|
+
id="Password"
|
|
284
|
+
defaultMessage="Password"
|
|
285
|
+
/>
|
|
286
|
+
</label>
|
|
287
|
+
</div>
|
|
288
|
+
</Grid.Column>
|
|
289
|
+
<Grid.Column stretched width="8">
|
|
290
|
+
<Input
|
|
291
|
+
type="password"
|
|
292
|
+
id="password"
|
|
293
|
+
autoComplete="current-password"
|
|
294
|
+
name="password"
|
|
295
|
+
placeholder={intl.formatMessage(messages.password)}
|
|
296
|
+
tabIndex={0}
|
|
297
|
+
/>
|
|
298
|
+
</Grid.Column>
|
|
299
|
+
</Grid.Row>
|
|
300
|
+
</Grid>
|
|
301
|
+
</Form.Field>
|
|
302
|
+
<Form.Field inline className="help">
|
|
303
|
+
<Grid>
|
|
304
|
+
<Grid.Row stretched>
|
|
305
|
+
{config.settings.showSelfRegistration && (
|
|
306
|
+
<Grid.Column stretched width="12">
|
|
307
|
+
<p className="help">
|
|
308
|
+
<Link to="/register">
|
|
309
|
+
{intl.formatMessage(messages.register)}
|
|
310
|
+
</Link>
|
|
311
|
+
</p>
|
|
312
|
+
</Grid.Column>
|
|
313
|
+
)}
|
|
314
|
+
<Grid.Column stretched width="12">
|
|
315
|
+
<p className="help">
|
|
316
|
+
<Link to="/passwordreset">
|
|
317
|
+
{intl.formatMessage(messages.forgotPassword)}
|
|
318
|
+
</Link>
|
|
319
|
+
</p>
|
|
320
|
+
</Grid.Column>
|
|
321
|
+
</Grid.Row>
|
|
322
|
+
</Grid>
|
|
323
|
+
</Form.Field>
|
|
324
|
+
</Form>
|
|
325
|
+
</Segment>
|
|
326
|
+
|
|
327
|
+
<Segment className="actions" clearing>
|
|
328
|
+
<Button
|
|
329
|
+
basic
|
|
330
|
+
primary
|
|
331
|
+
icon
|
|
332
|
+
floated="right"
|
|
333
|
+
type="submit"
|
|
334
|
+
form="login-form"
|
|
335
|
+
id="login-form-submit"
|
|
336
|
+
aria-label={intl.formatMessage(messages.login)}
|
|
337
|
+
title={intl.formatMessage(messages.login)}
|
|
338
|
+
loading={ploneLoading}
|
|
339
|
+
onClick={onLogin}
|
|
340
|
+
>
|
|
341
|
+
<Icon className="circled" name={aheadSVG} size="30px" />
|
|
342
|
+
</Button>
|
|
343
|
+
|
|
344
|
+
<Button
|
|
345
|
+
basic
|
|
346
|
+
secondary
|
|
347
|
+
icon
|
|
348
|
+
floated="right"
|
|
349
|
+
id="login-form-cancel"
|
|
350
|
+
as={Link}
|
|
351
|
+
to="/"
|
|
352
|
+
aria-label={intl.formatMessage(messages.cancel)}
|
|
353
|
+
title={intl.formatMessage(messages.cancel)}
|
|
354
|
+
>
|
|
355
|
+
<Icon className="circled" name={clearSVG} size="30px" />
|
|
356
|
+
</Button>
|
|
357
|
+
</Segment>
|
|
358
|
+
</Segment.Group>
|
|
359
|
+
|
|
360
|
+
{/* External Login Providers - Outside the main form */}
|
|
361
|
+
{validProviders && validProviders.length > 0 && (
|
|
362
|
+
<div style={{ marginTop: '2rem', width: '100%' }}>
|
|
363
|
+
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
|
|
364
|
+
<FormattedMessage
|
|
365
|
+
id="Or sign in with EEA Entra ID:"
|
|
366
|
+
defaultMessage="Or sign in with EEA Entra ID:"
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
<div style={{ width: '100%' }}>
|
|
370
|
+
{!loading && validProviders && (
|
|
371
|
+
<AuthProviders
|
|
372
|
+
providers={validProviders}
|
|
373
|
+
action="login"
|
|
374
|
+
onSelectProvider={onSelectProvider}
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
377
|
+
{(loading || validProviders.length === 0) && (
|
|
378
|
+
<div style={{ textAlign: 'center', padding: '1rem' }}>
|
|
379
|
+
{intl.formatMessage(messages.loading)}
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
</Container>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export default injectIntl(Login);
|
|
@@ -79,6 +79,7 @@ class Html extends Component {
|
|
|
79
79
|
store: PropTypes.shape({
|
|
80
80
|
getState: PropTypes.func,
|
|
81
81
|
}).isRequired,
|
|
82
|
+
nonce: PropTypes.string,
|
|
82
83
|
};
|
|
83
84
|
|
|
84
85
|
/**
|
|
@@ -94,10 +95,12 @@ class Html extends Component {
|
|
|
94
95
|
criticalCss,
|
|
95
96
|
apiPath,
|
|
96
97
|
publicURL,
|
|
98
|
+
nonce,
|
|
97
99
|
} = this.props;
|
|
98
100
|
const head = Helmet.rewind();
|
|
99
101
|
const bodyClass = join(BodyClass.rewind(), ' ');
|
|
100
102
|
const htmlAttributes = head.htmlAttributes.toComponent();
|
|
103
|
+
const helmetScripts = head.script.toComponent();
|
|
101
104
|
|
|
102
105
|
return (
|
|
103
106
|
<html lang={htmlAttributes.lang}>
|
|
@@ -107,9 +110,14 @@ class Html extends Component {
|
|
|
107
110
|
{head.title.toComponent()}
|
|
108
111
|
{head.meta.toComponent()}
|
|
109
112
|
{head.link.toComponent()}
|
|
110
|
-
{
|
|
113
|
+
{React.Children.map(helmetScripts, (elem) =>
|
|
114
|
+
React.isValidElement(elem)
|
|
115
|
+
? React.cloneElement(elem, { nonce })
|
|
116
|
+
: elem,
|
|
117
|
+
)}
|
|
111
118
|
|
|
112
119
|
<script
|
|
120
|
+
nonce={nonce}
|
|
113
121
|
dangerouslySetInnerHTML={{
|
|
114
122
|
__html: `window.env = ${serialize({
|
|
115
123
|
...runtimeConfig,
|
|
@@ -138,6 +146,7 @@ class Html extends Component {
|
|
|
138
146
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
139
147
|
{process.env.NODE_ENV === 'production' && criticalCss && (
|
|
140
148
|
<style
|
|
149
|
+
nonce={nonce}
|
|
141
150
|
dangerouslySetInnerHTML={{ __html: this.props.criticalCss }}
|
|
142
151
|
/>
|
|
143
152
|
)}
|
|
@@ -159,6 +168,7 @@ class Html extends Component {
|
|
|
159
168
|
criticalCss ? (
|
|
160
169
|
<>
|
|
161
170
|
<script
|
|
171
|
+
nonce={nonce}
|
|
162
172
|
dangerouslySetInnerHTML={{
|
|
163
173
|
__html: CRITICAL_CSS_TEMPLATE,
|
|
164
174
|
}}
|
|
@@ -185,6 +195,7 @@ class Html extends Component {
|
|
|
185
195
|
<div id="main" dangerouslySetInnerHTML={{ __html: markup }} />
|
|
186
196
|
<div role="complementary" aria-label="Sidebar" id="sidebar" />
|
|
187
197
|
<script
|
|
198
|
+
nonce={nonce}
|
|
188
199
|
dangerouslySetInnerHTML={{
|
|
189
200
|
__html: `window.__data=${serialize(
|
|
190
201
|
loadReducers(store.getState()),
|
|
@@ -196,6 +207,7 @@ class Html extends Component {
|
|
|
196
207
|
{this.props.extractScripts !== false
|
|
197
208
|
? extractor.getScriptElements().map((elem) =>
|
|
198
209
|
React.cloneElement(elem, {
|
|
210
|
+
nonce,
|
|
199
211
|
crossOrigin:
|
|
200
212
|
process.env.NODE_ENV === 'production' ? undefined : 'true',
|
|
201
213
|
}),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Customized for CSP nonce support.
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/* eslint no-console: 0 */
|
|
2
|
+
import '@plone/volto/config'; // This is the bootstrap for the global config - server side
|
|
3
|
+
import { existsSync, lstatSync, readFileSync } from 'fs';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { StaticRouter } from 'react-router-dom';
|
|
6
|
+
import { Provider } from 'react-intl-redux';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { renderToString } from 'react-dom/server';
|
|
9
|
+
import { createMemoryHistory } from 'history';
|
|
10
|
+
import { parse as parseUrl } from 'url';
|
|
11
|
+
import { keys } from 'lodash';
|
|
12
|
+
import locale from 'locale';
|
|
13
|
+
import { detect } from 'detect-browser';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';
|
|
16
|
+
import { resetServerContext } from 'react-beautiful-dnd';
|
|
17
|
+
import { CookiesProvider } from 'react-cookie';
|
|
18
|
+
import cookiesMiddleware from 'universal-cookie-express';
|
|
19
|
+
import debug from 'debug';
|
|
20
|
+
import crypto from 'crypto';
|
|
21
|
+
|
|
22
|
+
import routes from '@root/routes';
|
|
23
|
+
import config from '@plone/volto/registry';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
flattenToAppURL,
|
|
27
|
+
Html,
|
|
28
|
+
Api,
|
|
29
|
+
persistAuthToken,
|
|
30
|
+
toBackendLang,
|
|
31
|
+
toGettextLang,
|
|
32
|
+
toReactIntlLang,
|
|
33
|
+
} from '@plone/volto/helpers';
|
|
34
|
+
import { changeLanguage } from '@plone/volto/actions';
|
|
35
|
+
|
|
36
|
+
import userSession from '@plone/volto/reducers/userSession/userSession';
|
|
37
|
+
|
|
38
|
+
import ErrorPage from '@plone/volto/error';
|
|
39
|
+
|
|
40
|
+
import languages from '@plone/volto/constants/Languages';
|
|
41
|
+
|
|
42
|
+
import configureStore from '@plone/volto/store';
|
|
43
|
+
import {
|
|
44
|
+
ReduxAsyncConnect,
|
|
45
|
+
loadOnServer,
|
|
46
|
+
} from '@plone/volto/helpers/AsyncConnect';
|
|
47
|
+
|
|
48
|
+
let locales = {};
|
|
49
|
+
const cspConfig = process.env.CSP_HEADER || config.settings.serverConfig?.csp;
|
|
50
|
+
|
|
51
|
+
if (config.settings) {
|
|
52
|
+
config.settings.supportedLanguages.forEach((lang) => {
|
|
53
|
+
const langFileName = toGettextLang(lang);
|
|
54
|
+
import('@root/../locales/' + langFileName + '.json').then((locale) => {
|
|
55
|
+
locales = { ...locales, [toReactIntlLang(lang)]: locale.default };
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function reactIntlErrorHandler(error) {
|
|
61
|
+
debug('i18n')(error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const supported = new locale.Locales(keys(languages), 'en');
|
|
65
|
+
|
|
66
|
+
const server = express()
|
|
67
|
+
.disable('x-powered-by')
|
|
68
|
+
.head('/*', function (req, res) {
|
|
69
|
+
// Support for HEAD requests. Required by start-test utility in CI.
|
|
70
|
+
res.send('');
|
|
71
|
+
})
|
|
72
|
+
.use(cookiesMiddleware());
|
|
73
|
+
|
|
74
|
+
const middleware = (config.settings.expressMiddleware || []).filter((m) => m);
|
|
75
|
+
|
|
76
|
+
server.all('*', setupServer);
|
|
77
|
+
if (middleware.length) server.use('/', middleware);
|
|
78
|
+
|
|
79
|
+
// eslint-disable-next-line no-unused-vars
|
|
80
|
+
server.use(function (err, req, res, next) {
|
|
81
|
+
if (err) {
|
|
82
|
+
const { store } = res.locals;
|
|
83
|
+
const errorPage = (
|
|
84
|
+
<Provider store={store} onError={reactIntlErrorHandler}>
|
|
85
|
+
<StaticRouter context={{}} location={req.url}>
|
|
86
|
+
<ErrorPage message={err.message} />
|
|
87
|
+
</StaticRouter>
|
|
88
|
+
</Provider>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
res.set({
|
|
92
|
+
'Cache-Control': 'public, max-age=60, no-transform',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/* Displays error in console
|
|
96
|
+
* TODO:
|
|
97
|
+
* - get ignored codes from Plone error_log
|
|
98
|
+
*/
|
|
99
|
+
const ignoredErrors = [301, 302, 401, 404];
|
|
100
|
+
if (!ignoredErrors.includes(err.status)) console.error(err);
|
|
101
|
+
|
|
102
|
+
res
|
|
103
|
+
.status(err.status || 500) // If error happens in Volto code itself error status is undefined
|
|
104
|
+
.send(`<!doctype html> ${renderToString(errorPage)}`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function buildCSPHeader(opts, nonce) {
|
|
109
|
+
const nonceValue = `'nonce-${nonce}'`;
|
|
110
|
+
|
|
111
|
+
if (typeof opts === 'string') {
|
|
112
|
+
return opts.replaceAll('{nonce}', nonceValue);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return Object.keys(opts)
|
|
116
|
+
.sort()
|
|
117
|
+
.reduce((acc, key) => {
|
|
118
|
+
const value = Array.isArray(opts[key]) ? opts[key].join(' ') : opts[key];
|
|
119
|
+
return [...acc, `${key} ${value.replaceAll('{nonce}', nonceValue)}`];
|
|
120
|
+
}, [])
|
|
121
|
+
.join('; ');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setupServer(req, res, next) {
|
|
125
|
+
if (cspConfig) {
|
|
126
|
+
res.locals.nonce = crypto.randomBytes(16).toString('base64');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const api = new Api(req);
|
|
130
|
+
|
|
131
|
+
const lang = toReactIntlLang(
|
|
132
|
+
new locale.Locales(
|
|
133
|
+
req.universalCookies.get('I18N_LANGUAGE') ||
|
|
134
|
+
config.settings.defaultLanguage ||
|
|
135
|
+
req.headers['accept-language'],
|
|
136
|
+
)
|
|
137
|
+
.best(supported)
|
|
138
|
+
.toString(),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Minimum initial state for the fake Redux store instance
|
|
142
|
+
const initialState = {
|
|
143
|
+
intl: {
|
|
144
|
+
defaultLocale: 'en',
|
|
145
|
+
locale: lang,
|
|
146
|
+
messages: locales[lang],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const history = createMemoryHistory({
|
|
151
|
+
initialEntries: [req.url],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Create a fake Redux store instance for the `errorHandler` to render
|
|
155
|
+
// and for being used by the rest of the middlewares, if required
|
|
156
|
+
const store = configureStore(initialState, history, api);
|
|
157
|
+
|
|
158
|
+
function errorHandler(error) {
|
|
159
|
+
const errorPage = (
|
|
160
|
+
<Provider store={store} onError={reactIntlErrorHandler}>
|
|
161
|
+
<StaticRouter context={{}} location={req.url}>
|
|
162
|
+
<ErrorPage message={error.message} />
|
|
163
|
+
</StaticRouter>
|
|
164
|
+
</Provider>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
res.set({
|
|
168
|
+
'Cache-Control': 'public, max-age=60, no-transform',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/* Displays error in console
|
|
172
|
+
* TODO:
|
|
173
|
+
* - get ignored codes from Plone error_log
|
|
174
|
+
*/
|
|
175
|
+
const ignoredErrors = [301, 302, 401, 404];
|
|
176
|
+
if (!ignoredErrors.includes(error.status)) console.error(error);
|
|
177
|
+
|
|
178
|
+
res
|
|
179
|
+
.status(error.status || 500) // If error happens in Volto code itself error status is undefined
|
|
180
|
+
.send(`<!doctype html> ${renderToString(errorPage)}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!process.env.RAZZLE_API_PATH && req.headers.host) {
|
|
184
|
+
res.locals.detectedHost = `${
|
|
185
|
+
req.headers['x-forwarded-proto'] || req.protocol
|
|
186
|
+
}://${req.headers.host}`;
|
|
187
|
+
config.settings.apiPath = res.locals.detectedHost;
|
|
188
|
+
config.settings.publicURL = res.locals.detectedHost;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
res.locals = {
|
|
192
|
+
...res.locals,
|
|
193
|
+
store,
|
|
194
|
+
api,
|
|
195
|
+
errorHandler,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
next();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
server.get('/*', (req, res) => {
|
|
202
|
+
const { errorHandler, nonce } = res.locals;
|
|
203
|
+
|
|
204
|
+
if (cspConfig) {
|
|
205
|
+
res.setHeader('Content-Security-Policy', buildCSPHeader(cspConfig, nonce));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const api = new Api(req);
|
|
209
|
+
|
|
210
|
+
const browserdetect = detect(req.headers['user-agent']);
|
|
211
|
+
|
|
212
|
+
const lang = toReactIntlLang(
|
|
213
|
+
new locale.Locales(
|
|
214
|
+
req.universalCookies.get('I18N_LANGUAGE') ||
|
|
215
|
+
config.settings.defaultLanguage ||
|
|
216
|
+
req.headers['accept-language'],
|
|
217
|
+
)
|
|
218
|
+
.best(supported)
|
|
219
|
+
.toString(),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const authToken = req.universalCookies.get('auth_token');
|
|
223
|
+
const initialState = {
|
|
224
|
+
userSession: { ...userSession(), token: authToken },
|
|
225
|
+
form: req.body,
|
|
226
|
+
intl: {
|
|
227
|
+
defaultLocale: 'en',
|
|
228
|
+
locale: lang,
|
|
229
|
+
messages: locales[lang],
|
|
230
|
+
},
|
|
231
|
+
browserdetect,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const history = createMemoryHistory({
|
|
235
|
+
initialEntries: [req.url],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Create a new Redux store instance
|
|
239
|
+
const store = configureStore(initialState, history, api);
|
|
240
|
+
|
|
241
|
+
persistAuthToken(store, req);
|
|
242
|
+
|
|
243
|
+
// @loadable/server extractor
|
|
244
|
+
const buildDir = process.env.BUILD_DIR || 'build';
|
|
245
|
+
const extractor = new ChunkExtractor({
|
|
246
|
+
statsFile: path.resolve(path.join(buildDir, 'loadable-stats.json')),
|
|
247
|
+
entrypoints: ['client'],
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const url = req.originalUrl || req.url;
|
|
251
|
+
const location = parseUrl(url);
|
|
252
|
+
|
|
253
|
+
loadOnServer({ store, location, routes, api })
|
|
254
|
+
.then(() => {
|
|
255
|
+
const initialLang =
|
|
256
|
+
req.universalCookies.get('I18N_LANGUAGE') ||
|
|
257
|
+
config.settings.defaultLanguage ||
|
|
258
|
+
req.headers['accept-language'];
|
|
259
|
+
|
|
260
|
+
// The content info is in the store at this point thanks to the asynconnect
|
|
261
|
+
// features, then we can force the current language info into the store when
|
|
262
|
+
// coming from an SSR request
|
|
263
|
+
|
|
264
|
+
// TODO: there is a bug here with content that, for any reason, doesn't
|
|
265
|
+
// present the language token field, for some reason. In this case, we
|
|
266
|
+
// should follow the cookie rather then switching the language
|
|
267
|
+
const contentLang = store.getState().content.get?.error
|
|
268
|
+
? initialLang
|
|
269
|
+
: store.getState().content.data?.language?.token ||
|
|
270
|
+
config.settings.defaultLanguage;
|
|
271
|
+
|
|
272
|
+
if (toBackendLang(initialLang) !== contentLang && url !== '/') {
|
|
273
|
+
const newLang = toReactIntlLang(
|
|
274
|
+
new locale.Locales(contentLang).best(supported).toString(),
|
|
275
|
+
);
|
|
276
|
+
store.dispatch(changeLanguage(newLang, locales[newLang], req));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const context = {};
|
|
280
|
+
resetServerContext();
|
|
281
|
+
const markup = renderToString(
|
|
282
|
+
<ChunkExtractorManager extractor={extractor}>
|
|
283
|
+
<CookiesProvider cookies={req.universalCookies}>
|
|
284
|
+
<Provider store={store} onError={reactIntlErrorHandler}>
|
|
285
|
+
<StaticRouter context={context} location={req.url}>
|
|
286
|
+
<ReduxAsyncConnect routes={routes} helpers={api} />
|
|
287
|
+
</StaticRouter>
|
|
288
|
+
</Provider>
|
|
289
|
+
</CookiesProvider>
|
|
290
|
+
</ChunkExtractorManager>,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const readCriticalCss =
|
|
294
|
+
config.settings.serverConfig.readCriticalCss || defaultReadCriticalCss;
|
|
295
|
+
|
|
296
|
+
// If we are showing an "old browser" warning,
|
|
297
|
+
// make sure it doesn't get cached in a shared cache
|
|
298
|
+
const browserdetect = store.getState().browserdetect;
|
|
299
|
+
if (config.settings.notSupportedBrowsers.includes(browserdetect?.name)) {
|
|
300
|
+
res.set({
|
|
301
|
+
'Cache-Control': 'private',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (context.url) {
|
|
306
|
+
res.redirect(flattenToAppURL(context.url));
|
|
307
|
+
} else if (context.error_code) {
|
|
308
|
+
res.set({
|
|
309
|
+
'Cache-Control': 'no-cache',
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
res.status(context.error_code).send(
|
|
313
|
+
`<!doctype html>
|
|
314
|
+
${renderToString(
|
|
315
|
+
<Html
|
|
316
|
+
extractor={extractor}
|
|
317
|
+
nonce={nonce}
|
|
318
|
+
markup={markup}
|
|
319
|
+
store={store}
|
|
320
|
+
extractScripts={
|
|
321
|
+
config.settings.serverConfig.extractScripts?.errorPages ||
|
|
322
|
+
process.env.NODE_ENV !== 'production'
|
|
323
|
+
}
|
|
324
|
+
criticalCss={readCriticalCss(req)}
|
|
325
|
+
apiPath={res.locals.detectedHost || config.settings.apiPath}
|
|
326
|
+
publicURL={
|
|
327
|
+
res.locals.detectedHost || config.settings.publicURL
|
|
328
|
+
}
|
|
329
|
+
/>,
|
|
330
|
+
)}
|
|
331
|
+
`,
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
res.status(200).send(
|
|
335
|
+
`<!doctype html>
|
|
336
|
+
${renderToString(
|
|
337
|
+
<Html
|
|
338
|
+
extractor={extractor}
|
|
339
|
+
nonce={nonce}
|
|
340
|
+
markup={markup}
|
|
341
|
+
store={store}
|
|
342
|
+
criticalCss={readCriticalCss(req)}
|
|
343
|
+
apiPath={res.locals.detectedHost || config.settings.apiPath}
|
|
344
|
+
publicURL={
|
|
345
|
+
res.locals.detectedHost || config.settings.publicURL
|
|
346
|
+
}
|
|
347
|
+
/>,
|
|
348
|
+
)}
|
|
349
|
+
`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}, errorHandler)
|
|
353
|
+
.catch(errorHandler);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
export const defaultReadCriticalCss = () => {
|
|
357
|
+
const { criticalCssPath } = config.settings.serverConfig;
|
|
358
|
+
|
|
359
|
+
const e = existsSync(criticalCssPath);
|
|
360
|
+
if (!e) return;
|
|
361
|
+
|
|
362
|
+
const f = lstatSync(criticalCssPath);
|
|
363
|
+
if (!f.isFile()) return;
|
|
364
|
+
|
|
365
|
+
return readFileSync(criticalCssPath, { encoding: 'utf-8' });
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// Exposed for the console bootstrap info messages
|
|
369
|
+
server.apiPath = config.settings.apiPath;
|
|
370
|
+
server.devProxyToApiPath = config.settings.devProxyToApiPath;
|
|
371
|
+
server.proxyRewriteTarget = config.settings.proxyRewriteTarget;
|
|
372
|
+
server.publicURL = config.settings.publicURL;
|
|
373
|
+
|
|
374
|
+
export { buildCSPHeader };
|
|
375
|
+
export default server;
|
package/src/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import customBlocks, {
|
|
|
4
4
|
} from '@eeacms/volto-clms-theme/components/Blocks/customBlocks';
|
|
5
5
|
|
|
6
6
|
// ROUTE VIEWS
|
|
7
|
-
import { ContactForm, Search, Sitemap
|
|
7
|
+
import { ContactForm, Search, Sitemap } from '@plone/volto/components';
|
|
8
8
|
|
|
9
9
|
// VIEWS
|
|
10
10
|
import CLMSDatasetDetailView from '@eeacms/volto-clms-theme/components/CLMSDatasetDetailView/CLMSDatasetDetailView';
|
|
@@ -52,7 +52,7 @@ import { CheckboxHtmlWidget } from './components/Blocks/CustomTemplates/VoltoFor
|
|
|
52
52
|
import reducers from './reducers';
|
|
53
53
|
import CookieBanner from 'volto-cookie-banner/CookieBannerContainer';
|
|
54
54
|
import CLMSLoginView from './components/CLMSLoginView/CLMSLogin';
|
|
55
|
-
|
|
55
|
+
import AuthomaticLoginPlone from './components/CLMSLoginView/AuthomaticLoginPlone';
|
|
56
56
|
//SLATE CONFIGURATION
|
|
57
57
|
import installLinkEditor from '@plone/volto-slate/editor/plugins/AdvancedLink';
|
|
58
58
|
|
|
@@ -68,6 +68,20 @@ import ImageView from '@plone/volto/components/theme/View/ImageView';
|
|
|
68
68
|
import userSessionResetMiddleware from './store/userSessionResetMiddleware';
|
|
69
69
|
|
|
70
70
|
const applyConfig = (config) => {
|
|
71
|
+
if (__SERVER__) {
|
|
72
|
+
const devsource = __DEVELOPMENT__
|
|
73
|
+
? ` http://localhost:${parseInt(process.env.PORT || '3000') + 1}`
|
|
74
|
+
: '';
|
|
75
|
+
|
|
76
|
+
config.settings.serverConfig = {
|
|
77
|
+
...config.settings.serverConfig,
|
|
78
|
+
csp: {
|
|
79
|
+
'object-src': "'none'",
|
|
80
|
+
'script-src': `'self' {nonce}${devsource}`,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
config.views = {
|
|
72
86
|
...config.views,
|
|
73
87
|
contentTypesViews: {
|
|
@@ -260,11 +274,11 @@ const applyConfig = (config) => {
|
|
|
260
274
|
...config.addonRoutes,
|
|
261
275
|
{
|
|
262
276
|
path: '/login-plone',
|
|
263
|
-
component:
|
|
277
|
+
component: AuthomaticLoginPlone,
|
|
264
278
|
},
|
|
265
279
|
{
|
|
266
280
|
path: '/**/login-plone',
|
|
267
|
-
component:
|
|
281
|
+
component: AuthomaticLoginPlone,
|
|
268
282
|
},
|
|
269
283
|
{
|
|
270
284
|
path: '/login',
|