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