@plone-collective/volto-identity 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.changelog.draft +261 -0
  2. package/.release-it.json +34 -0
  3. package/CHANGELOG.md +269 -0
  4. package/LICENSE +21 -0
  5. package/README.md +228 -0
  6. package/babel.config.js +17 -0
  7. package/locales/de/LC_MESSAGES/volto.po +1162 -0
  8. package/locales/en/LC_MESSAGES/volto.po +1162 -0
  9. package/locales/es/LC_MESSAGES/volto.po +1169 -0
  10. package/locales/pt_BR/LC_MESSAGES/volto.po +1167 -0
  11. package/locales/volto.pot +1164 -0
  12. package/news/.changelog_template.jinja +15 -0
  13. package/news/.gitkeep +0 -0
  14. package/package.json +66 -0
  15. package/public/.gitkeep +0 -0
  16. package/src/actions/actions.test.ts +226 -0
  17. package/src/actions/index.ts +530 -0
  18. package/src/components/.gitkeep +0 -0
  19. package/src/components/Applications/Applications.tsx +169 -0
  20. package/src/components/Applications/ApplicationsPanel.scss +59 -0
  21. package/src/components/Applications/ApplicationsPanel.stories.tsx +82 -0
  22. package/src/components/Applications/ApplicationsPanel.test.tsx +235 -0
  23. package/src/components/Applications/ApplicationsPanel.tsx +342 -0
  24. package/src/components/Callback/Callback.scss +17 -0
  25. package/src/components/Callback/Callback.stories.tsx +43 -0
  26. package/src/components/Callback/Callback.test.tsx +262 -0
  27. package/src/components/Callback/Callback.tsx +170 -0
  28. package/src/components/Consent/Consent.test.tsx +75 -0
  29. package/src/components/Consent/Consent.tsx +98 -0
  30. package/src/components/Consent/ConsentPanel.scss +37 -0
  31. package/src/components/Consent/ConsentPanel.stories.tsx +80 -0
  32. package/src/components/Consent/ConsentPanel.test.tsx +138 -0
  33. package/src/components/Consent/ConsentPanel.tsx +162 -0
  34. package/src/components/ControlPanel/ClientsControlPanel.stories.tsx +38 -0
  35. package/src/components/ControlPanel/ClientsControlPanel.tsx +321 -0
  36. package/src/components/ControlPanel/ClientsPanel.scss +21 -0
  37. package/src/components/ControlPanel/ClientsPanel.stories.tsx +71 -0
  38. package/src/components/ControlPanel/ClientsPanel.test.tsx +267 -0
  39. package/src/components/ControlPanel/ClientsPanel.tsx +386 -0
  40. package/src/components/ControlPanel/ConfirmModal.stories.tsx +35 -0
  41. package/src/components/ControlPanel/ConfirmModal.test.tsx +66 -0
  42. package/src/components/ControlPanel/ConfirmModal.tsx +68 -0
  43. package/src/components/ControlPanel/ProviderForm.stories.tsx +107 -0
  44. package/src/components/ControlPanel/ProvidersControlPanel.scss +26 -0
  45. package/src/components/ControlPanel/ProvidersControlPanel.stories.tsx +88 -0
  46. package/src/components/ControlPanel/ProvidersControlPanel.tsx +600 -0
  47. package/src/components/ControlPanel/RenderUsers.stories.tsx +112 -0
  48. package/src/components/ControlPanel/RenderUsers.test.tsx +145 -0
  49. package/src/components/ControlPanel/RenderUsers.tsx +263 -0
  50. package/src/components/ControlPanel/SecretReveal.scss +32 -0
  51. package/src/components/ControlPanel/SecretReveal.stories.tsx +33 -0
  52. package/src/components/ControlPanel/SecretReveal.test.tsx +101 -0
  53. package/src/components/ControlPanel/SecretReveal.tsx +105 -0
  54. package/src/components/ControlPanel/UserAccount.stories.tsx +69 -0
  55. package/src/components/ControlPanel/UserAccount.test.tsx +117 -0
  56. package/src/components/ControlPanel/UserAccount.tsx +125 -0
  57. package/src/components/ControlPanel/UserAccountPanel.scss +38 -0
  58. package/src/components/ControlPanel/UserAccountPanel.stories.tsx +77 -0
  59. package/src/components/ControlPanel/UserAccountPanel.test.tsx +173 -0
  60. package/src/components/ControlPanel/UserAccountPanel.tsx +273 -0
  61. package/src/components/FirstLogin/FirstLogin.scss +12 -0
  62. package/src/components/FirstLogin/FirstLogin.stories.tsx +50 -0
  63. package/src/components/FirstLogin/FirstLogin.tsx +94 -0
  64. package/src/components/Identities/Identities.stories.tsx +51 -0
  65. package/src/components/Identities/Identities.test.tsx +134 -0
  66. package/src/components/Identities/Identities.tsx +217 -0
  67. package/src/components/Identities/IdentitiesList.scss +49 -0
  68. package/src/components/Identities/IdentitiesList.stories.tsx +71 -0
  69. package/src/components/Identities/IdentitiesList.test.tsx +218 -0
  70. package/src/components/Identities/IdentitiesList.tsx +207 -0
  71. package/src/components/Identities/ProfileEmails.scss +62 -0
  72. package/src/components/Identities/ProfileEmails.stories.tsx +75 -0
  73. package/src/components/Identities/ProfileEmails.test.tsx +132 -0
  74. package/src/components/Identities/ProfileEmails.tsx +217 -0
  75. package/src/components/Login/Login.stories.tsx +63 -0
  76. package/src/components/Login/Login.test.tsx +189 -0
  77. package/src/components/Login/Login.tsx +163 -0
  78. package/src/components/Login/LoginForm.scss +39 -0
  79. package/src/components/Login/LoginForm.stories.tsx +112 -0
  80. package/src/components/Login/LoginForm.test.tsx +483 -0
  81. package/src/components/Login/LoginForm.tsx +321 -0
  82. package/src/components/Login/LoginOverlay.scss +44 -0
  83. package/src/components/Login/LoginOverlay.stories.tsx +73 -0
  84. package/src/components/Login/LoginOverlay.test.tsx +98 -0
  85. package/src/components/Login/LoginOverlay.tsx +107 -0
  86. package/src/components/Login/LoginPanel.scss +50 -0
  87. package/src/components/Login/LoginPanel.stories.tsx +40 -0
  88. package/src/components/Login/LoginPanel.tsx +60 -0
  89. package/src/components/Login/MagicLinkForm.scss +25 -0
  90. package/src/components/Login/MagicLinkForm.stories.tsx +27 -0
  91. package/src/components/Login/MagicLinkForm.tsx +133 -0
  92. package/src/components/Login/PasswordForm.scss +82 -0
  93. package/src/components/Login/PasswordForm.stories.tsx +26 -0
  94. package/src/components/Login/PasswordForm.tsx +159 -0
  95. package/src/components/Login/ProviderButton.scss +103 -0
  96. package/src/components/Login/ProviderButton.stories.tsx +74 -0
  97. package/src/components/Login/ProviderButton.test.tsx +99 -0
  98. package/src/components/Login/ProviderButton.tsx +97 -0
  99. package/src/components/ProfileGate/ProfileGate.stories.tsx +118 -0
  100. package/src/components/ProfileGate/ProfileGate.test.tsx +370 -0
  101. package/src/components/ProfileGate/ProfileGate.tsx +172 -0
  102. package/src/components/Toolbar/PersonalTools.stories.tsx +111 -0
  103. package/src/components/Toolbar/PersonalTools.test.tsx +165 -0
  104. package/src/components/Toolbar/PersonalTools.tsx +177 -0
  105. package/src/components/Toolbar/Toolbar.jsx +766 -0
  106. package/src/components/UserMenu/ApplicationsMenuItem.stories.tsx +62 -0
  107. package/src/components/UserMenu/ApplicationsMenuItem.test.tsx +109 -0
  108. package/src/components/UserMenu/ApplicationsMenuItem.tsx +82 -0
  109. package/src/components/UserMenu/IdentitiesMenuItem.stories.tsx +35 -0
  110. package/src/components/UserMenu/IdentitiesMenuItem.test.tsx +103 -0
  111. package/src/components/UserMenu/IdentitiesMenuItem.tsx +34 -0
  112. package/src/components/UserMenu/MenuItem.stories.tsx +32 -0
  113. package/src/components/UserMenu/MenuItem.tsx +48 -0
  114. package/src/components/UserMenu/ProfileMenuItem.stories.tsx +76 -0
  115. package/src/components/UserMenu/ProfileMenuItem.test.tsx +90 -0
  116. package/src/components/UserMenu/ProfileMenuItem.tsx +60 -0
  117. package/src/components/UserMenu/UserAvatar.scss +23 -0
  118. package/src/components/UserMenu/UserAvatar.stories.tsx +121 -0
  119. package/src/components/UserMenu/UserAvatar.test.tsx +120 -0
  120. package/src/components/UserMenu/UserAvatar.tsx +119 -0
  121. package/src/components/UserMenu/UserMenuPlugs.stories.tsx +101 -0
  122. package/src/components/UserMenu/UserMenuPlugs.test.tsx +202 -0
  123. package/src/components/UserMenu/UserMenuPlugs.tsx +142 -0
  124. package/src/components/UserMenu/UserProfileLoader.test.tsx +211 -0
  125. package/src/components/UserMenu/UserProfileLoader.tsx +54 -0
  126. package/src/components/Views/GroupView.scss +36 -0
  127. package/src/components/Views/GroupView.stories.tsx +98 -0
  128. package/src/components/Views/GroupView.test.tsx +137 -0
  129. package/src/components/Views/GroupView.tsx +178 -0
  130. package/src/components/Views/ProfileView.scss +27 -0
  131. package/src/components/Views/ProfileView.stories.tsx +34 -0
  132. package/src/components/Views/ProfileView.test.tsx +82 -0
  133. package/src/components/Views/ProfileView.tsx +100 -0
  134. package/src/components/Widgets/ProviderIconWidget.scss +21 -0
  135. package/src/components/Widgets/ProviderIconWidget.stories.tsx +30 -0
  136. package/src/components/Widgets/ProviderIconWidget.test.tsx +55 -0
  137. package/src/components/Widgets/ProviderIconWidget.tsx +143 -0
  138. package/src/config/menu.ts +35 -0
  139. package/src/config/reducers.ts +7 -0
  140. package/src/config/routes.test.ts +177 -0
  141. package/src/config/routes.ts +143 -0
  142. package/src/config/settings.test.ts +34 -0
  143. package/src/config/settings.ts +72 -0
  144. package/src/config/views.ts +30 -0
  145. package/src/config/widgets.ts +25 -0
  146. package/src/constants/ActionTypes.ts +42 -0
  147. package/src/constants/vocabularies.ts +20 -0
  148. package/src/customizations/volto/components/manage/Controlpanels/Users/RenderUsers.tsx +23 -0
  149. package/src/customizations/volto/components/manage/Toolbar/PersonalTools.tsx +21 -0
  150. package/src/customizations/volto/components/manage/Toolbar/Toolbar.jsx +21 -0
  151. package/src/helpers/avatar.test.ts +79 -0
  152. package/src/helpers/avatar.ts +85 -0
  153. package/src/helpers/callback.test.ts +60 -0
  154. package/src/helpers/callback.ts +71 -0
  155. package/src/helpers/clientSchema.test.ts +182 -0
  156. package/src/helpers/clientSchema.ts +181 -0
  157. package/src/helpers/firstLogin.test.ts +90 -0
  158. package/src/helpers/firstLogin.ts +76 -0
  159. package/src/helpers/groupmap.test.ts +81 -0
  160. package/src/helpers/groupmap.ts +50 -0
  161. package/src/helpers/identities.ts +22 -0
  162. package/src/helpers/navigate.test.ts +132 -0
  163. package/src/helpers/navigate.ts +126 -0
  164. package/src/helpers/profileGate.test.ts +150 -0
  165. package/src/helpers/profileGate.ts +191 -0
  166. package/src/helpers/profileSource.ts +41 -0
  167. package/src/helpers/propertymap.test.ts +71 -0
  168. package/src/helpers/propertymap.ts +43 -0
  169. package/src/helpers/providerSchema.test.ts +410 -0
  170. package/src/helpers/providerSchema.ts +362 -0
  171. package/src/helpers/returnUrl.test.ts +42 -0
  172. package/src/helpers/returnUrl.ts +41 -0
  173. package/src/helpers/rowmap.ts +110 -0
  174. package/src/helpers/showPloneLogin.test.ts +78 -0
  175. package/src/helpers/showPloneLogin.ts +55 -0
  176. package/src/helpers/token.test.ts +62 -0
  177. package/src/helpers/token.ts +71 -0
  178. package/src/index.ts +22 -0
  179. package/src/reducers/index.ts +369 -0
  180. package/src/reducers/reducers.test.ts +369 -0
  181. package/src/stories/fixtures.tsx +640 -0
  182. package/src/storybook/withPage.test.tsx +90 -0
  183. package/src/storybook/withPage.tsx +110 -0
  184. package/src/storybook/withUserMenu.test.tsx +78 -0
  185. package/src/storybook/withUserMenu.tsx +136 -0
  186. package/src/styles.css +262 -0
  187. package/src/testing.tsx +86 -0
  188. package/src/types.ts +441 -0
  189. package/towncrier.toml +38 -0
  190. package/tsconfig.json +22 -0
  191. package/vitest.config.mjs +47 -0
@@ -0,0 +1,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
+ });