@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,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The applications a user has authorized, without store or routing.
|
|
3
|
+
*
|
|
4
|
+
* The mirror image of the identities page: that one lists the providers
|
|
5
|
+
* somebody signs in *with*, this one the applications they signed in *to*.
|
|
6
|
+
*
|
|
7
|
+
* Shaped like the control panels — a table of what exists, then one
|
|
8
|
+
* application's details on their own — for the same reason those are: a row
|
|
9
|
+
* per thing scans, and the detail nobody is looking at costs nothing to read.
|
|
10
|
+
* The claims in particular are the reason: a person scanning four
|
|
11
|
+
* applications wants their names, and a person deciding whether to withdraw
|
|
12
|
+
* one wants every field it can read.
|
|
13
|
+
*
|
|
14
|
+
* Two things it is careful about, and both are about not lying to the reader.
|
|
15
|
+
* It names what each scope actually releases rather than the scope itself,
|
|
16
|
+
* because "profile" tells a person nothing while "name, preferred_username,
|
|
17
|
+
* picture" is what they agreed to hand over. And it says out loud that
|
|
18
|
+
* withdrawing does not stop an access token already issued: those are
|
|
19
|
+
* self-encoded with no denylist and live out their lifetime, so a screen
|
|
20
|
+
* promising an instant cutoff would be promising something the server cannot
|
|
21
|
+
* do.
|
|
22
|
+
* @module components/Applications/ApplicationsPanel
|
|
23
|
+
*/
|
|
24
|
+
import React from 'react';
|
|
25
|
+
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|
26
|
+
import { Button, Table } from 'semantic-ui-react';
|
|
27
|
+
|
|
28
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
29
|
+
import backSVG from '@plone/volto/icons/back.svg';
|
|
30
|
+
import deleteSVG from '@plone/volto/icons/delete.svg';
|
|
31
|
+
import rightArrowSVG from '@plone/volto/icons/right-key.svg';
|
|
32
|
+
|
|
33
|
+
import type { OAuthGrant, OAuthGrants } from '../../types';
|
|
34
|
+
|
|
35
|
+
import './ApplicationsPanel.scss';
|
|
36
|
+
|
|
37
|
+
const messages = defineMessages({
|
|
38
|
+
loading: {
|
|
39
|
+
id: 'Loading the applications you have authorized',
|
|
40
|
+
defaultMessage: 'Loading the applications you have authorized…',
|
|
41
|
+
},
|
|
42
|
+
empty: {
|
|
43
|
+
id: 'You have not authorized any application.',
|
|
44
|
+
defaultMessage:
|
|
45
|
+
'You have not authorized any application. Anything you sign in to with ' +
|
|
46
|
+
'this account will ask you first, and appear here afterwards.',
|
|
47
|
+
},
|
|
48
|
+
unavailable: {
|
|
49
|
+
id: 'That list could not be loaded.',
|
|
50
|
+
defaultMessage: 'That list could not be loaded.',
|
|
51
|
+
},
|
|
52
|
+
columnApplication: { id: 'Application', defaultMessage: 'Application' },
|
|
53
|
+
columnAuthorized: { id: 'Authorized', defaultMessage: 'Authorized' },
|
|
54
|
+
columnReads: { id: 'Reads', defaultMessage: 'Reads' },
|
|
55
|
+
columnActions: { id: 'Actions', defaultMessage: 'Actions' },
|
|
56
|
+
details: { id: 'Details', defaultMessage: 'Details' },
|
|
57
|
+
back: { id: 'Back to the list', defaultMessage: 'Back to the list' },
|
|
58
|
+
nothing: { id: 'Nothing', defaultMessage: 'Nothing' },
|
|
59
|
+
fields: {
|
|
60
|
+
id: '{count, plural, one {# field} other {# fields}}',
|
|
61
|
+
defaultMessage: '{count, plural, one {# field} other {# fields}}',
|
|
62
|
+
},
|
|
63
|
+
granted: {
|
|
64
|
+
id: 'You authorized this application on {date}.',
|
|
65
|
+
defaultMessage: 'You authorized this application on {date}.',
|
|
66
|
+
},
|
|
67
|
+
canRead: { id: 'It can read:', defaultMessage: 'It can read:' },
|
|
68
|
+
readsNothing: {
|
|
69
|
+
id: 'Nothing beyond the fact that you signed in.',
|
|
70
|
+
defaultMessage: 'Nothing beyond the fact that you signed in.',
|
|
71
|
+
},
|
|
72
|
+
scopes: { id: 'Scopes agreed to', defaultMessage: 'Scopes agreed to' },
|
|
73
|
+
unregistered: {
|
|
74
|
+
id: 'This application is no longer registered on this site.',
|
|
75
|
+
defaultMessage:
|
|
76
|
+
'This application is no longer registered on this site. It cannot ask ' +
|
|
77
|
+
'for anything, and you can remove the record.',
|
|
78
|
+
},
|
|
79
|
+
disabled: {
|
|
80
|
+
id: 'This application is disabled and is being refused already.',
|
|
81
|
+
defaultMessage:
|
|
82
|
+
'This application is disabled and is being refused already.',
|
|
83
|
+
},
|
|
84
|
+
withdraw: { id: 'Withdraw access', defaultMessage: 'Withdraw access' },
|
|
85
|
+
lingering: {
|
|
86
|
+
id: 'Withdrawing signs it out and makes it ask again.',
|
|
87
|
+
defaultMessage:
|
|
88
|
+
'Withdrawing signs an application out everywhere and makes it ask you ' +
|
|
89
|
+
'again next time. An access token it already holds keeps working for ' +
|
|
90
|
+
'up to {minutes, plural, one {# minute} other {# minutes}} — this site ' +
|
|
91
|
+
'cannot recall one that was already issued.',
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
interface ApplicationsPanelProps {
|
|
96
|
+
grants: OAuthGrants | null;
|
|
97
|
+
loading: boolean;
|
|
98
|
+
/** Whether the listing could not be read. */
|
|
99
|
+
error?: unknown;
|
|
100
|
+
/** The application whose details are on screen, if any. */
|
|
101
|
+
selected: string | null;
|
|
102
|
+
/** The client currently being withdrawn, if any. */
|
|
103
|
+
withdrawing: string | null;
|
|
104
|
+
onSelect: (clientId: string | null) => void;
|
|
105
|
+
onWithdraw: (clientId: string) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every field an application can read, named once.
|
|
110
|
+
*
|
|
111
|
+
* Deduplicated across scopes: the reader wants the list of things, not the
|
|
112
|
+
* list of scopes that happen to contain them.
|
|
113
|
+
*
|
|
114
|
+
* @param grant The agreement.
|
|
115
|
+
* @returns The claim names.
|
|
116
|
+
*/
|
|
117
|
+
function claimsOf(grant: OAuthGrant): string[] {
|
|
118
|
+
return Array.from(new Set(grant.scopes.flatMap((scope) => scope.claims)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ApplicationsPanel: React.FC<ApplicationsPanelProps> = ({
|
|
122
|
+
grants,
|
|
123
|
+
loading,
|
|
124
|
+
error,
|
|
125
|
+
selected,
|
|
126
|
+
withdrawing,
|
|
127
|
+
onSelect,
|
|
128
|
+
onWithdraw,
|
|
129
|
+
}) => {
|
|
130
|
+
const intl = useIntl();
|
|
131
|
+
|
|
132
|
+
if (loading || (!grants && !error)) {
|
|
133
|
+
return (
|
|
134
|
+
<div className="identity-applications" role="status">
|
|
135
|
+
{intl.formatMessage(messages.loading)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!grants) {
|
|
141
|
+
return (
|
|
142
|
+
<div className="identity-applications">
|
|
143
|
+
<p className="identity-error" role="alert">
|
|
144
|
+
{intl.formatMessage(messages.unavailable)}
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** How long access already granted can outlive a withdrawal. */
|
|
151
|
+
const lingering = (
|
|
152
|
+
<p className="identity-applications__lingering identity-note">
|
|
153
|
+
<FormattedMessage
|
|
154
|
+
{...messages.lingering}
|
|
155
|
+
values={{
|
|
156
|
+
// Rounded up: saying "up to 1 minute" for 90 seconds would
|
|
157
|
+
// understate it.
|
|
158
|
+
minutes: Math.max(1, Math.ceil(grants.access_token_ttl / 60)),
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
</p>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const current = selected
|
|
165
|
+
? grants.items.find((item) => item.client_id === selected)
|
|
166
|
+
: undefined;
|
|
167
|
+
|
|
168
|
+
if (current) {
|
|
169
|
+
const claims = claimsOf(current);
|
|
170
|
+
const busy = withdrawing === current.client_id;
|
|
171
|
+
return (
|
|
172
|
+
<div className="identity-applications" data-client={current.client_id}>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
className="identity-applications__back"
|
|
176
|
+
onClick={() => onSelect(null)}
|
|
177
|
+
>
|
|
178
|
+
<Icon name={backSVG} size="18px" />
|
|
179
|
+
{intl.formatMessage(messages.back)}
|
|
180
|
+
</button>
|
|
181
|
+
|
|
182
|
+
<h2>
|
|
183
|
+
{current.title}{' '}
|
|
184
|
+
<small>
|
|
185
|
+
<code>{current.client_id}</code>
|
|
186
|
+
</small>
|
|
187
|
+
</h2>
|
|
188
|
+
<p className="identity-note">
|
|
189
|
+
{intl.formatMessage(messages.granted, {
|
|
190
|
+
// The date only. The hour somebody clicked Allow is not
|
|
191
|
+
// something they remember, and printing it invites them to try.
|
|
192
|
+
date: intl.formatDate(current.granted_at, {
|
|
193
|
+
year: 'numeric',
|
|
194
|
+
month: 'long',
|
|
195
|
+
day: 'numeric',
|
|
196
|
+
}),
|
|
197
|
+
})}
|
|
198
|
+
</p>
|
|
199
|
+
|
|
200
|
+
{!current.registered ? (
|
|
201
|
+
<p className="identity-note" role="status">
|
|
202
|
+
{intl.formatMessage(messages.unregistered)}
|
|
203
|
+
</p>
|
|
204
|
+
) : !current.enabled ? (
|
|
205
|
+
<p className="identity-note" role="status">
|
|
206
|
+
{intl.formatMessage(messages.disabled)}
|
|
207
|
+
</p>
|
|
208
|
+
) : null}
|
|
209
|
+
|
|
210
|
+
<h3>{intl.formatMessage(messages.canRead)}</h3>
|
|
211
|
+
{claims.length ? (
|
|
212
|
+
<ul className="identity-applications__claims">
|
|
213
|
+
{claims.map((claim) => (
|
|
214
|
+
<li key={claim}>
|
|
215
|
+
<code>{claim}</code>
|
|
216
|
+
</li>
|
|
217
|
+
))}
|
|
218
|
+
</ul>
|
|
219
|
+
) : (
|
|
220
|
+
<p className="identity-note">
|
|
221
|
+
{intl.formatMessage(messages.readsNothing)}
|
|
222
|
+
</p>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{/* The scopes as well as the claims, on the detail view only. They
|
|
226
|
+
are what the client asked for and what a support conversation
|
|
227
|
+
will be about; the claims are what the person actually cares
|
|
228
|
+
about, which is why the list above leads. */}
|
|
229
|
+
<h3>{intl.formatMessage(messages.scopes)}</h3>
|
|
230
|
+
<p>
|
|
231
|
+
<code>{current.scopes.map((scope) => scope.id).join(' ')}</code>
|
|
232
|
+
</p>
|
|
233
|
+
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className="identity-button identity-button--danger"
|
|
237
|
+
data-action="withdraw"
|
|
238
|
+
disabled={busy}
|
|
239
|
+
onClick={() => onWithdraw(current.client_id)}
|
|
240
|
+
>
|
|
241
|
+
{intl.formatMessage(messages.withdraw)}
|
|
242
|
+
</button>
|
|
243
|
+
|
|
244
|
+
{lingering}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!grants.items.length) {
|
|
250
|
+
return (
|
|
251
|
+
<div className="identity-applications">
|
|
252
|
+
<p className="identity-applications__empty identity-note">
|
|
253
|
+
{intl.formatMessage(messages.empty)}
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div className="identity-applications">
|
|
261
|
+
<Table selectable compact>
|
|
262
|
+
<Table.Header>
|
|
263
|
+
<Table.Row>
|
|
264
|
+
<Table.HeaderCell>
|
|
265
|
+
{intl.formatMessage(messages.columnApplication)}
|
|
266
|
+
</Table.HeaderCell>
|
|
267
|
+
<Table.HeaderCell>
|
|
268
|
+
{intl.formatMessage(messages.columnAuthorized)}
|
|
269
|
+
</Table.HeaderCell>
|
|
270
|
+
<Table.HeaderCell>
|
|
271
|
+
{intl.formatMessage(messages.columnReads)}
|
|
272
|
+
</Table.HeaderCell>
|
|
273
|
+
<Table.HeaderCell textAlign="right">
|
|
274
|
+
{intl.formatMessage(messages.columnActions)}
|
|
275
|
+
</Table.HeaderCell>
|
|
276
|
+
</Table.Row>
|
|
277
|
+
</Table.Header>
|
|
278
|
+
<Table.Body>
|
|
279
|
+
{grants.items.map((grant) => {
|
|
280
|
+
const count = claimsOf(grant).length;
|
|
281
|
+
return (
|
|
282
|
+
<Table.Row key={grant.client_id} data-client={grant.client_id}>
|
|
283
|
+
<Table.Cell>
|
|
284
|
+
{grant.title}
|
|
285
|
+
{!grant.registered || !grant.enabled ? (
|
|
286
|
+
<p className="identity-note" role="status">
|
|
287
|
+
{intl.formatMessage(
|
|
288
|
+
grant.registered
|
|
289
|
+
? messages.disabled
|
|
290
|
+
: messages.unregistered,
|
|
291
|
+
)}
|
|
292
|
+
</p>
|
|
293
|
+
) : null}
|
|
294
|
+
</Table.Cell>
|
|
295
|
+
<Table.Cell>
|
|
296
|
+
{intl.formatDate(grant.granted_at, {
|
|
297
|
+
year: 'numeric',
|
|
298
|
+
month: 'long',
|
|
299
|
+
day: 'numeric',
|
|
300
|
+
})}
|
|
301
|
+
</Table.Cell>
|
|
302
|
+
<Table.Cell>
|
|
303
|
+
{/* A count in the row, the names on the detail view: four
|
|
304
|
+
rows of claim lists is a wall nobody reads. */}
|
|
305
|
+
{count
|
|
306
|
+
? intl.formatMessage(messages.fields, { count })
|
|
307
|
+
: intl.formatMessage(messages.nothing)}
|
|
308
|
+
</Table.Cell>
|
|
309
|
+
<Table.Cell textAlign="right">
|
|
310
|
+
<Button
|
|
311
|
+
basic
|
|
312
|
+
icon
|
|
313
|
+
aria-label={intl.formatMessage(messages.details)}
|
|
314
|
+
title={intl.formatMessage(messages.details)}
|
|
315
|
+
onClick={() => onSelect(grant.client_id)}
|
|
316
|
+
>
|
|
317
|
+
<Icon name={rightArrowSVG} size="20px" />
|
|
318
|
+
</Button>
|
|
319
|
+
<Button
|
|
320
|
+
basic
|
|
321
|
+
icon
|
|
322
|
+
data-action="withdraw"
|
|
323
|
+
disabled={withdrawing === grant.client_id}
|
|
324
|
+
aria-label={intl.formatMessage(messages.withdraw)}
|
|
325
|
+
title={intl.formatMessage(messages.withdraw)}
|
|
326
|
+
onClick={() => onWithdraw(grant.client_id)}
|
|
327
|
+
>
|
|
328
|
+
<Icon name={deleteSVG} size="20px" />
|
|
329
|
+
</Button>
|
|
330
|
+
</Table.Cell>
|
|
331
|
+
</Table.Row>
|
|
332
|
+
);
|
|
333
|
+
})}
|
|
334
|
+
</Table.Body>
|
|
335
|
+
</Table>
|
|
336
|
+
|
|
337
|
+
{lingering}
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export default ApplicationsPanel;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a provider redirects back to: one line, and rarely for long.
|
|
3
|
+
*
|
|
4
|
+
* It sits inside `LoginPanel`, so the card, its width and its centring come
|
|
5
|
+
* from there. This only centres the line within it -- the way
|
|
6
|
+
* `volto-authomatic` centres its logout message -- and colours a refusal.
|
|
7
|
+
*/
|
|
8
|
+
.identity-callback {
|
|
9
|
+
padding: var(--identity-gap) 0;
|
|
10
|
+
margin: 0;
|
|
11
|
+
color: var(--identity-muted);
|
|
12
|
+
text-align: center;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.identity-callback--error {
|
|
16
|
+
color: var(--identity-error);
|
|
17
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import Callback from './Callback';
|
|
4
|
+
import { FAILED, LOADED, LOADING, withStore } from '../../stories/fixtures';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Callback> = {
|
|
7
|
+
title: 'Identity/Callback',
|
|
8
|
+
component: Callback,
|
|
9
|
+
parameters: { reactRouter: { location: '/login-identity?code=a&state=b' } },
|
|
10
|
+
};
|
|
11
|
+
export default meta;
|
|
12
|
+
|
|
13
|
+
type Story = StoryObj<typeof Callback>;
|
|
14
|
+
|
|
15
|
+
/** What the user sees for the moment the exchange takes. */
|
|
16
|
+
export const Working: Story = {
|
|
17
|
+
decorators: [withStore({ identityCallback: LOADING, magicLinkConfirm: {} })],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every backend refusal reads the same on purpose -- expired, replayed,
|
|
22
|
+
* forged and wrong-session are one message here, and the audit log carries
|
|
23
|
+
* the difference.
|
|
24
|
+
*/
|
|
25
|
+
export const Refused: Story = {
|
|
26
|
+
decorators: [withStore({ identityCallback: FAILED, magicLinkConfirm: {} })],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The success state is a moment: the token goes into the store and the
|
|
31
|
+
* browser leaves. There is nothing to look at, which is the point.
|
|
32
|
+
*/
|
|
33
|
+
export const SignedIn: Story = {
|
|
34
|
+
decorators: [
|
|
35
|
+
withStore({
|
|
36
|
+
identityCallback: {
|
|
37
|
+
...LOADED,
|
|
38
|
+
data: { token: 'a-token', came_from: '/' },
|
|
39
|
+
},
|
|
40
|
+
magicLinkConfirm: {},
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '../../testing';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import { MemoryRouter, useLocation } from 'react-router-dom';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import { LOGIN } from '@plone/volto/constants/ActionTypes';
|
|
8
|
+
import Callback from './Callback';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render the callback route with a store whose state we control.
|
|
12
|
+
*
|
|
13
|
+
* The component is the whole of the frontend's half of a login, and it had no
|
|
14
|
+
* test at all -- which is how it shipped fetching a token successfully and
|
|
15
|
+
* then doing nothing with it, leaving the page on "Signing you in…" with a
|
|
16
|
+
* 200 in the access log.
|
|
17
|
+
*/
|
|
18
|
+
function fakeStore(state: any) {
|
|
19
|
+
// react-redux only needs the store contract, and `redux` is not a
|
|
20
|
+
// dependency of this package.
|
|
21
|
+
const dispatched: any[] = [];
|
|
22
|
+
return {
|
|
23
|
+
dispatched,
|
|
24
|
+
store: {
|
|
25
|
+
getState: () => state,
|
|
26
|
+
dispatch: (action: any) => {
|
|
27
|
+
dispatched.push(action);
|
|
28
|
+
return action;
|
|
29
|
+
},
|
|
30
|
+
subscribe: () => () => {},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reports where the router currently is.
|
|
37
|
+
*
|
|
38
|
+
* These used to assert on `window.location.href`, because the component set
|
|
39
|
+
* it. It routes now, so the observable thing is the router's location -- and
|
|
40
|
+
* asserting on that is also what proves the page is no longer being thrown
|
|
41
|
+
* away to navigate.
|
|
42
|
+
*/
|
|
43
|
+
function Where() {
|
|
44
|
+
return <span data-testid="where">{useLocation().pathname}</span>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderCallback(state: any, search = '?code=abc&state=xyz') {
|
|
48
|
+
const { store, dispatched } = fakeStore(state);
|
|
49
|
+
render(
|
|
50
|
+
<Provider store={store as any}>
|
|
51
|
+
<MemoryRouter initialEntries={[`/login-identity${search}`]}>
|
|
52
|
+
<Callback />
|
|
53
|
+
<Where />
|
|
54
|
+
</MemoryRouter>
|
|
55
|
+
</Provider>,
|
|
56
|
+
);
|
|
57
|
+
return { dispatched };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PENDING = { identityCallback: { loading: true }, magicLinkConfirm: {} };
|
|
61
|
+
|
|
62
|
+
describe('Callback', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
// jsdom refuses a real navigation; the component only ever sets href.
|
|
65
|
+
delete (window as any).location;
|
|
66
|
+
(window as any).location = { href: '' };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('says it is working while the exchange is in flight', () => {
|
|
70
|
+
renderCallback(PENDING);
|
|
71
|
+
|
|
72
|
+
expect(screen.getByRole('status').textContent).toContain('Signing you in');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('signs the user in once the token arrives', () => {
|
|
76
|
+
// Volto's persistAuthToken subscribes to the store and writes any new
|
|
77
|
+
// userSession.token out to the cookie, so dispatching its LOGIN_SUCCESS
|
|
78
|
+
// *is* the sign-in. Without this the page never left "Signing you in…".
|
|
79
|
+
const { dispatched } = renderCallback({
|
|
80
|
+
identityCallback: { loaded: true, data: { token: 'a-token' } },
|
|
81
|
+
magicLinkConfirm: {},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(
|
|
85
|
+
dispatched.some(
|
|
86
|
+
(action) =>
|
|
87
|
+
action.type === `${LOGIN}_SUCCESS` &&
|
|
88
|
+
action.result?.token === 'a-token',
|
|
89
|
+
),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('sends the user where the flow started', () => {
|
|
94
|
+
renderCallback({
|
|
95
|
+
identityCallback: {
|
|
96
|
+
loaded: true,
|
|
97
|
+
data: { token: 'a-token', came_from: '/some/page' },
|
|
98
|
+
},
|
|
99
|
+
magicLinkConfirm: {},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(screen.getByTestId('where').textContent).toBe('/some/page');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('resumes an authorization request instead of routing to it', () => {
|
|
106
|
+
// The demo stack, end to end: the relying party sends the browser to
|
|
107
|
+
// `@@oauth-authorize`, the backend has no session yet and bounces it
|
|
108
|
+
// through `require_login`, and Plone hands `came_from` back
|
|
109
|
+
// *site-relative*. Routing that renders Volto's 404 at exactly the
|
|
110
|
+
// authorization URL, with the request's parameters replaced by Volto's
|
|
111
|
+
// own `expand=` ones -- so the relying party gets neither a code nor an
|
|
112
|
+
// error (Érico, 2026-08-30).
|
|
113
|
+
renderCallback({
|
|
114
|
+
identityCallback: {
|
|
115
|
+
loaded: true,
|
|
116
|
+
data: {
|
|
117
|
+
token: 'a-token',
|
|
118
|
+
came_from: '/@@oauth-authorize?response_type=code&client_id=demo-rp',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
magicLinkConfirm: {},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// The router stayed where it was: this is a real navigation.
|
|
125
|
+
expect(screen.getByTestId('where').textContent).toBe('/login-identity');
|
|
126
|
+
expect(window.location.href).toBe(
|
|
127
|
+
'/@@oauth-authorize?response_type=code&client_id=demo-rp',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('falls back to the site root when the flow named nowhere', () => {
|
|
132
|
+
renderCallback({
|
|
133
|
+
identityCallback: { loaded: true, data: { token: 'a-token' } },
|
|
134
|
+
magicLinkConfirm: {},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(screen.getByTestId('where').textContent).toBe('/');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('lets a caller override what happens with the token', () => {
|
|
141
|
+
const onToken = vi.fn();
|
|
142
|
+
const { store } = fakeStore({
|
|
143
|
+
identityCallback: {
|
|
144
|
+
loaded: true,
|
|
145
|
+
data: { token: 'a-token', came_from: '/x' },
|
|
146
|
+
},
|
|
147
|
+
magicLinkConfirm: {},
|
|
148
|
+
});
|
|
149
|
+
render(
|
|
150
|
+
<Provider store={store as any}>
|
|
151
|
+
<MemoryRouter initialEntries={['/login-identity?code=a&state=b']}>
|
|
152
|
+
<Callback onToken={onToken} />
|
|
153
|
+
</MemoryRouter>
|
|
154
|
+
</Provider>,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(onToken).toHaveBeenCalledWith('a-token', '/x');
|
|
158
|
+
// The override replaces the default rather than running alongside it.
|
|
159
|
+
// The override replaced the default, so nothing navigated.
|
|
160
|
+
expect(window.location.href).toBe('');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('renders inside the login card, not against the window edge', () => {
|
|
164
|
+
// It is the login page one redirect later. Rendered as a bare div it had
|
|
165
|
+
// no container at all, so its one line sat glued to the left edge.
|
|
166
|
+
renderCallback(PENDING);
|
|
167
|
+
|
|
168
|
+
expect(document.querySelector('#page-login .loginForm')).toBeTruthy();
|
|
169
|
+
expect(
|
|
170
|
+
document.querySelector('.loginForm .identity-callback'),
|
|
171
|
+
).toBeTruthy();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('leaves the description strip out, having nothing to put in it', () => {
|
|
175
|
+
renderCallback(PENDING);
|
|
176
|
+
|
|
177
|
+
expect(document.querySelector('#page-login .description')).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('reports a refusal rather than waiting forever', () => {
|
|
181
|
+
renderCallback({
|
|
182
|
+
identityCallback: { error: { status: 401 } },
|
|
183
|
+
magicLinkConfirm: {},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(screen.getByRole('alert').textContent).toContain('no longer valid');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('reports a callback that carries no credential at all', () => {
|
|
190
|
+
renderCallback(PENDING, '?nothing=here');
|
|
191
|
+
|
|
192
|
+
expect(screen.getByRole('alert').textContent).toContain('incomplete');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('reports a provider that refused', () => {
|
|
196
|
+
renderCallback(PENDING, '?error=access_denied');
|
|
197
|
+
|
|
198
|
+
expect(screen.getByRole('alert').textContent).toContain('refused');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('Callback and a confirmation link', () => {
|
|
203
|
+
beforeEach(() => {
|
|
204
|
+
delete (window as any).location;
|
|
205
|
+
(window as any).location = { href: '' };
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('sends the user back to their identities', () => {
|
|
209
|
+
// A confirmation link arrives on the same route, carrying the same
|
|
210
|
+
// `magic_link` parameter as a login link. Only the answer says which one
|
|
211
|
+
// it was -- and without this branch the page sat on "Signing you in…"
|
|
212
|
+
// waiting for a token that is never issued.
|
|
213
|
+
renderCallback(
|
|
214
|
+
{
|
|
215
|
+
identityCallback: {},
|
|
216
|
+
magicLinkConfirm: {
|
|
217
|
+
loaded: true,
|
|
218
|
+
data: { linked: { provider: 'email', subject: 'erico@plone.org' } },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
'?magic_link=a-token',
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(screen.getByTestId('where').textContent).toBe('/identities');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('does not sign anybody in', () => {
|
|
228
|
+
// The caller already had a session, and answering a confirmation with a
|
|
229
|
+
// fresh one would turn a stolen link into a login.
|
|
230
|
+
const { dispatched } = renderCallback(
|
|
231
|
+
{
|
|
232
|
+
identityCallback: {},
|
|
233
|
+
magicLinkConfirm: {
|
|
234
|
+
loaded: true,
|
|
235
|
+
data: { linked: { provider: 'email', subject: 'erico@plone.org' } },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
'?magic_link=a-token',
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(dispatched.some((a: any) => a.type === `${LOGIN}_SUCCESS`)).toBe(
|
|
242
|
+
false,
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('says what it is doing while confirming', () => {
|
|
247
|
+
renderCallback(
|
|
248
|
+
{
|
|
249
|
+
identityCallback: {},
|
|
250
|
+
magicLinkConfirm: {
|
|
251
|
+
loaded: true,
|
|
252
|
+
data: { linked: { provider: 'email', subject: 'erico@plone.org' } },
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
'?magic_link=a-token',
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(screen.getByRole('status').textContent).toContain(
|
|
259
|
+
'Confirming your address',
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
});
|