@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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route the provider redirects back to.
|
|
3
|
+
*
|
|
4
|
+
* This is the frontend half of the Volto-route callback shape: the provider
|
|
5
|
+
* sends the browser here with `code` and `state` on the query string, or the
|
|
6
|
+
* emailed magic link arrives with `magic_link`. Either way the credential is
|
|
7
|
+
* handed to the backend, which is the only thing that can validate it.
|
|
8
|
+
*
|
|
9
|
+
* It renders inside `LoginPanel`, the same card the login page is, because it
|
|
10
|
+
* is the same flow one redirect later -- this is what `volto-authomatic` does
|
|
11
|
+
* with its own callback. A bare `<div>` here put its one line against the left
|
|
12
|
+
* edge of the window, which reads as a broken page rather than a step.
|
|
13
|
+
* @module components/Callback/Callback
|
|
14
|
+
*/
|
|
15
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
16
|
+
import type { MessageDescriptor } from 'react-intl';
|
|
17
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
18
|
+
import { useHistory, useLocation } from 'react-router-dom';
|
|
19
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
20
|
+
import { LOGIN } from '@plone/volto/constants/ActionTypes';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
completeCallback,
|
|
24
|
+
confirmMagicLink,
|
|
25
|
+
listIdentities,
|
|
26
|
+
} from '../../actions';
|
|
27
|
+
import { readCallback } from '../../helpers/callback';
|
|
28
|
+
import { IDENTITIES_PATH } from '../../config/routes';
|
|
29
|
+
import LoginPanel from '../Login/LoginPanel';
|
|
30
|
+
|
|
31
|
+
import './Callback.scss';
|
|
32
|
+
import { goTo } from '../../helpers/navigate';
|
|
33
|
+
|
|
34
|
+
const messages = defineMessages({
|
|
35
|
+
// The same id the login page's heading uses: the same card, so the same
|
|
36
|
+
// word, and one translation covers both.
|
|
37
|
+
title: { id: 'Log in', defaultMessage: 'Log in' },
|
|
38
|
+
working: { id: 'Signing you in', defaultMessage: 'Signing you in…' },
|
|
39
|
+
linking: {
|
|
40
|
+
id: 'Confirming your address',
|
|
41
|
+
defaultMessage: 'Confirming your address…',
|
|
42
|
+
},
|
|
43
|
+
refused: {
|
|
44
|
+
id: 'The provider refused the sign-in.',
|
|
45
|
+
defaultMessage: 'The provider refused the sign-in.',
|
|
46
|
+
},
|
|
47
|
+
incomplete: {
|
|
48
|
+
id: 'This sign-in link is incomplete.',
|
|
49
|
+
defaultMessage: 'This sign-in link is incomplete.',
|
|
50
|
+
},
|
|
51
|
+
invalid: {
|
|
52
|
+
id: 'That sign-in link is no longer valid. Please start again.',
|
|
53
|
+
defaultMessage: 'That sign-in link is no longer valid. Please start again.',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
interface CallbackProps {
|
|
58
|
+
/** Called with the issued token instead of the default sign-in. */
|
|
59
|
+
onToken?: (token: string, cameFrom: string) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const Callback: React.FC<CallbackProps> = ({ onToken }) => {
|
|
63
|
+
const intl = useIntl();
|
|
64
|
+
const dispatch = useDispatch();
|
|
65
|
+
const location = useLocation();
|
|
66
|
+
const { push } = useHistory();
|
|
67
|
+
const dispatched = useRef(false);
|
|
68
|
+
// The descriptor rather than the formatted string: what is stored is
|
|
69
|
+
// which refusal happened, and rendering it is the renderer's job.
|
|
70
|
+
const [refusal, setRefusal] = useState<MessageDescriptor | null>(null);
|
|
71
|
+
|
|
72
|
+
const callback = useSelector((state: any) => state.identityCallback);
|
|
73
|
+
const magic = useSelector((state: any) => state.magicLinkConfirm);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (dispatched.current) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
dispatched.current = true;
|
|
80
|
+
|
|
81
|
+
const parsed = readCallback(location.search);
|
|
82
|
+
if (parsed.kind === 'magic-link') {
|
|
83
|
+
dispatch(confirmMagicLink(parsed.token as string));
|
|
84
|
+
} else if (parsed.kind === 'code') {
|
|
85
|
+
dispatch(
|
|
86
|
+
completeCallback(
|
|
87
|
+
parsed.provider as string,
|
|
88
|
+
parsed.code as string,
|
|
89
|
+
parsed.state as string,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
setRefusal(
|
|
94
|
+
parsed.kind === 'error' ? messages.refused : messages.incomplete,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}, [dispatch, location.search]);
|
|
98
|
+
|
|
99
|
+
const answered = callback?.loaded ? callback : magic?.loaded ? magic : null;
|
|
100
|
+
|
|
101
|
+
// A confirmation link carries the same `magic_link` parameter as a login
|
|
102
|
+
// link -- the difference is in the token, so it is the answer that reveals
|
|
103
|
+
// which one arrived.
|
|
104
|
+
const linked = Boolean(answered?.data?.linked);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!linked) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Back to where the flow was started from, through the router. The list
|
|
111
|
+
// there was fetched before this address existed, so it is refetched --
|
|
112
|
+
// which is what the full page load this used to do was really for, at the
|
|
113
|
+
// cost of throwing the whole application away to get it.
|
|
114
|
+
dispatch(listIdentities());
|
|
115
|
+
goTo(IDENTITIES_PATH, push);
|
|
116
|
+
}, [linked, dispatch, push]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const token = answered?.data?.token;
|
|
120
|
+
if (!token) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const cameFrom = answered?.data?.came_from ?? '';
|
|
124
|
+
if (onToken) {
|
|
125
|
+
onToken(token, cameFrom);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The default, and the reason this is not an optional prop with no
|
|
130
|
+
// fallback: the route is registered as a bare component, so nothing
|
|
131
|
+
// passes onToken, and a page that fetched a token and then did nothing
|
|
132
|
+
// with it sat on "Signing you in…" forever with a 200 in the access log.
|
|
133
|
+
//
|
|
134
|
+
// Volto's own LOGIN_SUCCESS is the whole sign-in: `persistAuthToken`
|
|
135
|
+
// subscribes to the store at client start and writes any new
|
|
136
|
+
// `userSession.token` out to the cookie, so putting the token in the
|
|
137
|
+
// store is what makes the browser signed in. Reproducing that by writing
|
|
138
|
+
// the cookie here would be a second implementation of it.
|
|
139
|
+
dispatch({ type: `${LOGIN}_SUCCESS`, result: { token } });
|
|
140
|
+
// Through the router: the store changing is what re-renders everything
|
|
141
|
+
// that was rendered for an anonymous user, which is what the page load
|
|
142
|
+
// this used to do was standing in for. `came_from` arrives in a query
|
|
143
|
+
// string, so `goTo` is also what stops it being an open redirect.
|
|
144
|
+
goTo(cameFrom || '/', push);
|
|
145
|
+
}, [answered, onToken, dispatch, push]);
|
|
146
|
+
|
|
147
|
+
// Every backend refusal reads the same on purpose: expired, replayed,
|
|
148
|
+
// forged and wrong-session are one message here, and the audit log carries
|
|
149
|
+
// the difference. A refusal parsed off the query string is the one thing
|
|
150
|
+
// this page can say more precisely, because no credential ever reached the
|
|
151
|
+
// backend for it to be vague about.
|
|
152
|
+
const failure =
|
|
153
|
+
refusal ?? (callback?.error || magic?.error ? messages.invalid : null);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<LoginPanel title={intl.formatMessage(messages.title)}>
|
|
157
|
+
{failure ? (
|
|
158
|
+
<p className="identity-callback identity-callback--error" role="alert">
|
|
159
|
+
{intl.formatMessage(failure)}
|
|
160
|
+
</p>
|
|
161
|
+
) : (
|
|
162
|
+
<p className="identity-callback" role="status">
|
|
163
|
+
{intl.formatMessage(linked ? messages.linking : messages.working)}
|
|
164
|
+
</p>
|
|
165
|
+
)}
|
|
166
|
+
</LoginPanel>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export default Callback;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { answerUrl } from './Consent';
|
|
4
|
+
import type { ConsentRequest } from '../../types';
|
|
5
|
+
|
|
6
|
+
const REQUEST: ConsentRequest = {
|
|
7
|
+
'@id': 'http://id.example.org/@oauth-consent',
|
|
8
|
+
client: { id: 'app', title: 'Example App' },
|
|
9
|
+
user: { id: 'alice', label: 'Alice Liddell' },
|
|
10
|
+
scopes: [{ id: 'openid', claims: [] }],
|
|
11
|
+
authorize_url: 'http://id.example.org/@@oauth-authorize',
|
|
12
|
+
params: {
|
|
13
|
+
response_type: 'code',
|
|
14
|
+
client_id: 'app',
|
|
15
|
+
redirect_uri: 'https://app.example.org/cb',
|
|
16
|
+
scope: 'openid profile',
|
|
17
|
+
state: 'xyz',
|
|
18
|
+
},
|
|
19
|
+
authenticator: 'tok3n',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The answer's parameters.
|
|
24
|
+
*
|
|
25
|
+
* @param allow Whether the user agreed.
|
|
26
|
+
* @returns The query parameters of the URL the browser would be sent to.
|
|
27
|
+
*/
|
|
28
|
+
function answer(allow: boolean) {
|
|
29
|
+
const url = new URL(answerUrl(REQUEST, allow));
|
|
30
|
+
return { url, params: Object.fromEntries(url.searchParams.entries()) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('answerUrl', () => {
|
|
34
|
+
it('sends the answer to the endpoint that decides', () => {
|
|
35
|
+
// Not to the relying party. Every check runs again there, so a client
|
|
36
|
+
// disabled between the question and the answer is refused on the way out.
|
|
37
|
+
expect(answer(true).url.origin + answer(true).url.pathname).toBe(
|
|
38
|
+
'http://id.example.org/@@oauth-authorize',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('hands the request back unchanged', () => {
|
|
43
|
+
const { params } = answer(true);
|
|
44
|
+
|
|
45
|
+
for (const [name, value] of Object.entries(REQUEST.params)) {
|
|
46
|
+
expect(params[name]).toBe(value);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('spells agreement out', () => {
|
|
51
|
+
// `allow` is the only value that means yes, because consent is the thing
|
|
52
|
+
// that has to be given explicitly.
|
|
53
|
+
expect(answer(true).params.consent).toBe('allow');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('sends a refusal rather than nothing', () => {
|
|
57
|
+
// Silence would leave the relying party waiting for a browser that is
|
|
58
|
+
// never coming back.
|
|
59
|
+
expect(answer(false).params.consent).toBe('deny');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('carries the token the answer is checked against', () => {
|
|
63
|
+
// A forged consent request is an attempt to authorize a client on
|
|
64
|
+
// somebody else's behalf.
|
|
65
|
+
expect(answer(true).params._authenticator).toBe('tok3n');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('adds nothing the request did not carry', () => {
|
|
69
|
+
const { params } = answer(true);
|
|
70
|
+
|
|
71
|
+
expect(Object.keys(params).sort()).toEqual(
|
|
72
|
+
[...Object.keys(REQUEST.params), 'consent', '_authenticator'].sort(),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent container: store, and the navigation back out to the server.
|
|
3
|
+
*
|
|
4
|
+
* The answer is a browser navigation rather than a fetch, and that is the
|
|
5
|
+
* whole shape of this component. `@@oauth-authorize` answers a decision with
|
|
6
|
+
* a 302 to the relying party's redirect URI -- it is the browser that has to
|
|
7
|
+
* arrive there, carrying whatever cookies the relying party set on the way
|
|
8
|
+
* out. An XHR would follow the redirect itself and hand this page a response
|
|
9
|
+
* body nobody can do anything with.
|
|
10
|
+
*
|
|
11
|
+
* So the page reads the request over the API, renders it, and then leaves.
|
|
12
|
+
* @module components/Consent/Consent
|
|
13
|
+
*/
|
|
14
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
15
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
16
|
+
import { useHistory, useLocation } from 'react-router-dom';
|
|
17
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
18
|
+
|
|
19
|
+
import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
|
|
20
|
+
|
|
21
|
+
import { getConsentRequest } from '../../actions';
|
|
22
|
+
import type { ConsentRequest } from '../../types';
|
|
23
|
+
import ConsentPanel from './ConsentPanel';
|
|
24
|
+
import { goTo } from '../../helpers/navigate';
|
|
25
|
+
|
|
26
|
+
const messages = defineMessages({
|
|
27
|
+
title: { id: 'Authorize', defaultMessage: 'Authorize' },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the URL that carries an answer back to the authorization endpoint.
|
|
32
|
+
*
|
|
33
|
+
* The request travels back exactly as it arrived. `consent=allow` is the only
|
|
34
|
+
* value that means yes -- anything else is a refusal, because consent is the
|
|
35
|
+
* thing that has to be given explicitly.
|
|
36
|
+
*
|
|
37
|
+
* @param request What the server said about this authorization request.
|
|
38
|
+
* @param allow Whether the user agreed.
|
|
39
|
+
* @returns The absolute URL to send the browser to.
|
|
40
|
+
*/
|
|
41
|
+
export function answerUrl(request: ConsentRequest, allow: boolean): string {
|
|
42
|
+
const params = new URLSearchParams(request.params);
|
|
43
|
+
params.set('consent', allow ? 'allow' : 'deny');
|
|
44
|
+
// plone.protect's token. The endpoint refuses an answer without a valid
|
|
45
|
+
// one: a forged consent request is an attempt to authorize a client on
|
|
46
|
+
// somebody else's behalf.
|
|
47
|
+
params.set('_authenticator', request.authenticator);
|
|
48
|
+
return `${request.authorize_url}?${params.toString()}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const Consent: React.FC = () => {
|
|
52
|
+
const intl = useIntl();
|
|
53
|
+
const dispatch = useDispatch();
|
|
54
|
+
const { search } = useLocation();
|
|
55
|
+
const { push } = useHistory();
|
|
56
|
+
// Set when the browser is on its way out. The buttons go dead rather than
|
|
57
|
+
// letting a second click send a second answer to a request that has
|
|
58
|
+
// already been decided.
|
|
59
|
+
const [answering, setAnswering] = useState(false);
|
|
60
|
+
|
|
61
|
+
const consent = useSelector((state: any) => state.consentRequest);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// The query string is handed on verbatim: an authorization request is
|
|
65
|
+
// compared byte for byte further down the flow.
|
|
66
|
+
dispatch(getConsentRequest(search));
|
|
67
|
+
}, [dispatch, search]);
|
|
68
|
+
|
|
69
|
+
const onAnswer = useCallback(
|
|
70
|
+
(allow: boolean) => {
|
|
71
|
+
if (!consent?.data) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
setAnswering(true);
|
|
75
|
+
goTo(answerUrl(consent.data, allow), push, { external: true });
|
|
76
|
+
},
|
|
77
|
+
[consent?.data, push],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<Helmet title={intl.formatMessage(messages.title)}>
|
|
83
|
+
{/* This page is one step of somebody's sign-in, reached with their
|
|
84
|
+
authorization request in the URL. It is not a page to index. */}
|
|
85
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
86
|
+
</Helmet>
|
|
87
|
+
<ConsentPanel
|
|
88
|
+
request={consent?.data ?? null}
|
|
89
|
+
loading={Boolean(consent?.loading)}
|
|
90
|
+
error={consent?.error}
|
|
91
|
+
answering={answering}
|
|
92
|
+
onAnswer={onAnswer}
|
|
93
|
+
/>
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export default Consent;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent screen.
|
|
3
|
+
*
|
|
4
|
+
* Sized like the login card, because it is the same moment of the same
|
|
5
|
+
* journey: a person is being asked one question and should see one column of
|
|
6
|
+
* text, not a page-wide layout. The card, the note and the button come from
|
|
7
|
+
* the root stylesheet.
|
|
8
|
+
*/
|
|
9
|
+
.identity-consent {
|
|
10
|
+
width: var(--identity-login-width);
|
|
11
|
+
max-width: 100%;
|
|
12
|
+
margin: 3rem auto;
|
|
13
|
+
line-height: 1.5;
|
|
14
|
+
|
|
15
|
+
h1 {
|
|
16
|
+
margin-top: 0;
|
|
17
|
+
font-size: 1.25rem;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.identity-consent__who {
|
|
22
|
+
margin-top: -0.5rem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.identity-consent__claims {
|
|
26
|
+
padding-left: 1.25rem;
|
|
27
|
+
|
|
28
|
+
li {
|
|
29
|
+
margin-bottom: 0.25rem;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.identity-consent__actions {
|
|
34
|
+
display: flex;
|
|
35
|
+
margin-top: 1.5rem;
|
|
36
|
+
gap: 0.75rem;
|
|
37
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import ConsentPanel from './ConsentPanel';
|
|
4
|
+
import { CONSENT_REQUEST, FAILED } from '../../stories/fixtures';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The consent screen.
|
|
8
|
+
*
|
|
9
|
+
* The reason it is a Volto route at all: a relying party sent this browser
|
|
10
|
+
* here to be asked something personal, and a screen that looks nothing like
|
|
11
|
+
* the site the person thinks they are signing in to is the screen they should
|
|
12
|
+
* not trust.
|
|
13
|
+
*/
|
|
14
|
+
const meta: Meta<typeof ConsentPanel> = {
|
|
15
|
+
title: 'Identity/Consent/ConsentPanel',
|
|
16
|
+
component: ConsentPanel,
|
|
17
|
+
args: {
|
|
18
|
+
request: CONSENT_REQUEST,
|
|
19
|
+
loading: false,
|
|
20
|
+
answering: false,
|
|
21
|
+
onAnswer: () => {},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export default meta;
|
|
25
|
+
|
|
26
|
+
type Story = StoryObj<typeof ConsentPanel>;
|
|
27
|
+
|
|
28
|
+
export const Asking: Story = {};
|
|
29
|
+
|
|
30
|
+
/** `openid` alone: an identity, and nothing else released. */
|
|
31
|
+
export const NothingReleased: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
request: { ...CONSENT_REQUEST, scopes: [{ id: 'openid', claims: [] }] },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A client asking for everything this server publishes. */
|
|
38
|
+
export const EverythingReleased: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
request: {
|
|
41
|
+
...CONSENT_REQUEST,
|
|
42
|
+
scopes: [
|
|
43
|
+
{ id: 'openid', claims: [] },
|
|
44
|
+
{
|
|
45
|
+
id: 'profile',
|
|
46
|
+
claims: [
|
|
47
|
+
'name',
|
|
48
|
+
'preferred_username',
|
|
49
|
+
'website',
|
|
50
|
+
'picture',
|
|
51
|
+
'description',
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{ id: 'email', claims: ['email', 'email_verified'] },
|
|
55
|
+
{ id: 'address', claims: ['address'] },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A client registered without a title is named by its id. */
|
|
62
|
+
export const AnUntitledClient: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
request: { ...CONSENT_REQUEST, client: { id: 'app', title: 'app' } },
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const Loading: Story = { args: { request: null, loading: true } };
|
|
69
|
+
|
|
70
|
+
/** The answer is on its way and the browser is leaving. */
|
|
71
|
+
export const Answering: Story = { args: { answering: true } };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A request this server would not honour: an unknown client, or a redirect
|
|
75
|
+
* URI that matches nothing registered. No buttons — an "Allow" here would be
|
|
76
|
+
* agreeing to something the page could not describe.
|
|
77
|
+
*/
|
|
78
|
+
export const Unavailable: Story = {
|
|
79
|
+
args: { request: null, error: FAILED.error },
|
|
80
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import ConsentPanel from './ConsentPanel';
|
|
6
|
+
import type { ConsentRequest } from '../../types';
|
|
7
|
+
|
|
8
|
+
const REQUEST: ConsentRequest = {
|
|
9
|
+
'@id': 'http://id.example.org/@oauth-consent',
|
|
10
|
+
client: { id: 'app', title: 'Example App' },
|
|
11
|
+
user: { id: 'alice', label: 'Alice Liddell' },
|
|
12
|
+
scopes: [
|
|
13
|
+
{ id: 'openid', claims: [] },
|
|
14
|
+
{ id: 'profile', claims: ['name', 'preferred_username'] },
|
|
15
|
+
{ id: 'email', claims: ['email', 'email_verified'] },
|
|
16
|
+
],
|
|
17
|
+
authorize_url: 'http://id.example.org/@@oauth-authorize',
|
|
18
|
+
params: { response_type: 'code', client_id: 'app' },
|
|
19
|
+
authenticator: 'token',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function renderPanel(
|
|
23
|
+
props: Partial<React.ComponentProps<typeof ConsentPanel>> = {},
|
|
24
|
+
) {
|
|
25
|
+
const onAnswer = vi.fn();
|
|
26
|
+
render(
|
|
27
|
+
<ConsentPanel
|
|
28
|
+
request={REQUEST}
|
|
29
|
+
loading={false}
|
|
30
|
+
answering={false}
|
|
31
|
+
onAnswer={onAnswer}
|
|
32
|
+
{...props}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
return { onAnswer };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('ConsentPanel', () => {
|
|
39
|
+
it('names the application doing the asking', () => {
|
|
40
|
+
renderPanel();
|
|
41
|
+
|
|
42
|
+
expect(screen.getByRole('heading').textContent).toContain('Example App');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('says who would be agreeing', () => {
|
|
46
|
+
// The browser may hold a session the user forgot about, and agreeing on
|
|
47
|
+
// behalf of the wrong account is the mistake this screen exists to make
|
|
48
|
+
// visible.
|
|
49
|
+
renderPanel();
|
|
50
|
+
|
|
51
|
+
expect(screen.getByText(/Alice Liddell/)).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('lists what would actually be released, not the scope names', () => {
|
|
55
|
+
// "profile" means nothing to the person being asked.
|
|
56
|
+
renderPanel();
|
|
57
|
+
|
|
58
|
+
expect(screen.getByText('preferred_username')).toBeTruthy();
|
|
59
|
+
expect(screen.queryByText('profile')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('names each claim once however many scopes carry it', () => {
|
|
63
|
+
renderPanel({
|
|
64
|
+
request: {
|
|
65
|
+
...REQUEST,
|
|
66
|
+
scopes: [
|
|
67
|
+
{ id: 'profile', claims: ['name'] },
|
|
68
|
+
{ id: 'other', claims: ['name'] },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(screen.getAllByText('name')).toHaveLength(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('says plainly when a request releases nothing', () => {
|
|
77
|
+
// `openid` alone asks for an identity and gates no claim. An empty list
|
|
78
|
+
// under "It will be able to read:" reads as a rendering failure.
|
|
79
|
+
renderPanel({
|
|
80
|
+
request: { ...REQUEST, scopes: [{ id: 'openid', claims: [] }] },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(screen.getByText(/nothing beyond the fact/)).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('offers both answers', () => {
|
|
87
|
+
renderPanel();
|
|
88
|
+
|
|
89
|
+
expect(screen.getByRole('button', { name: 'Allow' })).toBeTruthy();
|
|
90
|
+
expect(screen.getByRole('button', { name: 'Deny' })).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('reports agreement', () => {
|
|
94
|
+
const { onAnswer } = renderPanel();
|
|
95
|
+
|
|
96
|
+
fireEvent.click(screen.getByRole('button', { name: 'Allow' }));
|
|
97
|
+
|
|
98
|
+
expect(onAnswer).toHaveBeenCalledWith(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('reports refusal', () => {
|
|
102
|
+
const { onAnswer } = renderPanel();
|
|
103
|
+
|
|
104
|
+
fireEvent.click(screen.getByRole('button', { name: 'Deny' }));
|
|
105
|
+
|
|
106
|
+
expect(onAnswer).toHaveBeenCalledWith(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('takes no second answer once one is on its way', () => {
|
|
110
|
+
renderPanel({ answering: true });
|
|
111
|
+
|
|
112
|
+
expect(
|
|
113
|
+
(screen.getByRole('button', { name: 'Allow' }) as HTMLButtonElement)
|
|
114
|
+
.disabled,
|
|
115
|
+
).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('says so while the request is loading', () => {
|
|
119
|
+
renderPanel({ request: null, loading: true });
|
|
120
|
+
|
|
121
|
+
expect(screen.getByRole('status').textContent).toContain('Loading');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('waits rather than refusing before the first answer arrives', () => {
|
|
125
|
+
// No request and no error yet is the first render, not a failure.
|
|
126
|
+
renderPanel({ request: null, loading: false });
|
|
127
|
+
|
|
128
|
+
expect(screen.getByRole('status')).toBeTruthy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('offers no answer to a request it could not describe', () => {
|
|
132
|
+
// An "Allow" here would be agreeing to something the page cannot show.
|
|
133
|
+
renderPanel({ request: null, error: new Error('nope') });
|
|
134
|
+
|
|
135
|
+
expect(screen.getByRole('alert')).toBeTruthy();
|
|
136
|
+
expect(screen.queryByRole('button', { name: 'Allow' })).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|