@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,530 @@
1
+ /**
2
+ * Actions for the identity login flows.
3
+ *
4
+ * Every one of them is a plain Volto API action: the `request` key is picked
5
+ * up by Volto's api middleware, which does the fetch and dispatches the
6
+ * `_PENDING` / `_SUCCESS` / `_FAIL` triple.
7
+ * @module actions
8
+ */
9
+
10
+ import {
11
+ COMPLETE_CALLBACK,
12
+ CONFIRM_MAGIC_LINK,
13
+ GET_CONSENT_REQUEST,
14
+ LIST_GRANTS,
15
+ WITHDRAW_GRANT,
16
+ CREATE_CLIENT,
17
+ CREATE_PROVIDER,
18
+ DELETE_CLIENT,
19
+ DELETE_PROVIDER,
20
+ GET_MY_PROFILE,
21
+ GET_USER_ACCOUNT,
22
+ GET_USER_PROFILE,
23
+ LIST_GROUP_MEMBERS,
24
+ LIST_CLIENTS,
25
+ LIST_DRIVERS,
26
+ LIST_KEYS,
27
+ LIST_IDENTITIES,
28
+ LIST_LOGIN_PROVIDERS,
29
+ LIST_PROVIDERS,
30
+ ROTATE_CLIENT_SECRET,
31
+ ROTATE_KEY,
32
+ SEND_MAGIC_LINK,
33
+ SET_PREFERRED_EMAIL,
34
+ START_LINKING,
35
+ START_PROVIDER_LOGIN,
36
+ TEST_PROVIDER,
37
+ UNLINK_IDENTITY,
38
+ UPDATE_CLIENT,
39
+ UPDATE_PROVIDER,
40
+ } from '../constants/ActionTypes';
41
+
42
+ /**
43
+ * List the providers this site offers.
44
+ */
45
+ export function listLoginProviders() {
46
+ return {
47
+ type: LIST_LOGIN_PROVIDERS,
48
+ request: { op: 'get', path: '/@login-providers' },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Start an authorization-code flow against one provider.
54
+ *
55
+ * Separate from the listing on purpose: this mints the state, PKCE verifier
56
+ * and nonce, so calling it for every provider on page load would leave a pile
57
+ * of unused attempts behind.
58
+ *
59
+ * @param providerId Provider to log in with.
60
+ * @param cameFrom Where to send the user afterwards.
61
+ */
62
+ export function startProviderLogin(providerId: string, cameFrom = '') {
63
+ const query = cameFrom ? `?came_from=${encodeURIComponent(cameFrom)}` : '';
64
+ return {
65
+ type: START_PROVIDER_LOGIN,
66
+ request: { op: 'get', path: `/@login-providers/${providerId}${query}` },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Hand the provider's answer back to the backend.
72
+ *
73
+ * @param provider Provider the code came from.
74
+ * @param code The authorization code.
75
+ * @param state The state the provider echoed back.
76
+ */
77
+ export function completeCallback(
78
+ provider: string,
79
+ code: string,
80
+ state: string,
81
+ ) {
82
+ return {
83
+ type: COMPLETE_CALLBACK,
84
+ request: {
85
+ op: 'post',
86
+ path: '/@identity-callback',
87
+ data: { provider, code, state },
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Ask for a magic link.
94
+ *
95
+ * @param email Address to send it to.
96
+ */
97
+ export function sendMagicLink(email: string) {
98
+ return {
99
+ type: SEND_MAGIC_LINK,
100
+ request: { op: 'post', path: '/@magic-link', data: { email } },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Redeem a magic link.
106
+ *
107
+ * @param token The token from the emailed link.
108
+ */
109
+ export function confirmMagicLink(token: string) {
110
+ return {
111
+ type: CONFIRM_MAGIC_LINK,
112
+ request: {
113
+ op: 'post',
114
+ path: '/@magic-link-confirm',
115
+ data: { token },
116
+ },
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Read a user, with what this add-on knows about them.
122
+ *
123
+ * `@users/<userid>` is Plone's own endpoint; this package's serializer adds
124
+ * `identities`, `source` and `profile_url` to it, which is what the toolbar
125
+ * and the profile links read. Held separately from Volto's `state.users.user`
126
+ * on purpose: that one is fetched when the user menu opens and cleared
127
+ * around it, and the avatar has to survive a page the menu was never opened
128
+ * on.
129
+ *
130
+ * @param userid The user to read.
131
+ */
132
+ export function getUserProfile(userid: string) {
133
+ return {
134
+ type: GET_USER_PROFILE,
135
+ request: { op: 'get', path: `/@users/${userid}` },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * List the identities the signed-in user owns.
141
+ *
142
+ * The answer also carries `available`: every *enabled* provider the caller
143
+ * has not linked yet. That is a different question from what the login screen
144
+ * offers -- a provider taken off the login page is still one an existing user
145
+ * may attach -- so it is the endpoint's own answer rather than the expanded
146
+ * `login-providers` listing, which remains available for a caller that wants
147
+ * the login screen's view of things.
148
+ *
149
+ * @param withProviders Whether to expand the login providers alongside.
150
+ */
151
+ export function listIdentities(withProviders = false) {
152
+ const query = withProviders ? '?expand=login-providers' : '';
153
+ return {
154
+ type: LIST_IDENTITIES,
155
+ request: { op: 'get', path: `/@identities${query}` },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Start a flow that attaches another provider to the caller's account.
161
+ *
162
+ * Answers one of two shapes. A redirect provider gives back an
163
+ * `authorize_url` to follow; the email provider gives back `sent`, because
164
+ * its flow continues in a mailbox rather than in this tab.
165
+ *
166
+ * @param providerId Provider to link.
167
+ * @param cameFrom Where to send the user afterwards.
168
+ * @param email Address to confirm, for the email provider only.
169
+ */
170
+ export function startLinking(providerId: string, cameFrom = '', email = '') {
171
+ return {
172
+ type: START_LINKING,
173
+ request: {
174
+ op: 'post',
175
+ path: '/@identities',
176
+ // Sent only when there is one: the backend requires an address for the
177
+ // email provider and takes none for any other.
178
+ data: email
179
+ ? { provider: providerId, came_from: cameFrom, email }
180
+ : { provider: providerId, came_from: cameFrom },
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Detach one identity.
187
+ *
188
+ * @param provider Provider id.
189
+ * @param subject Provider-side subject.
190
+ */
191
+ export function unlinkIdentity(provider: string, subject: string) {
192
+ return {
193
+ type: UNLINK_IDENTITY,
194
+ request: {
195
+ op: 'del',
196
+ path: `/@identities/${encodeURIComponent(provider)}/${encodeURIComponent(subject)}`,
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * List the members of one group, nested memberships included.
203
+ *
204
+ * plone.restapi's `@groups/<id>` already carries member userids and already
205
+ * sees the nesting; what it cannot do is name each person, search within the
206
+ * group, or say what feeds into it. This does all three.
207
+ *
208
+ * @param groupId The group to read.
209
+ * @param query Case-insensitive substring, matched against name and login.
210
+ */
211
+ export function listGroupMembers(groupId: string, query = '') {
212
+ const search = query ? `?query=${encodeURIComponent(query)}` : '';
213
+ return {
214
+ type: LIST_GROUP_MEMBERS,
215
+ request: {
216
+ op: 'get',
217
+ path: `/@group-members/${encodeURIComponent(groupId)}${search}`,
218
+ },
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Read how one account gets in, and when it last did.
224
+ *
225
+ * `Manage users`, except when a caller asks about themselves.
226
+ *
227
+ * @param userid The account to read.
228
+ */
229
+ export function getUserAccount(userid: string) {
230
+ return {
231
+ type: GET_USER_ACCOUNT,
232
+ request: {
233
+ op: 'get',
234
+ path: `/@user-account/${encodeURIComponent(userid)}`,
235
+ },
236
+ };
237
+ }
238
+
239
+ /**
240
+ * List the drivers, with the schema the control-panel form renders from.
241
+ */
242
+ export function listDrivers() {
243
+ return {
244
+ type: LIST_DRIVERS,
245
+ request: { op: 'get', path: '/@identity-drivers' },
246
+ };
247
+ }
248
+
249
+ /**
250
+ * List the configured providers.
251
+ */
252
+ export function listProviders() {
253
+ return {
254
+ type: LIST_PROVIDERS,
255
+ request: { op: 'get', path: '/@identity-providers' },
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Create a provider.
261
+ *
262
+ * @param data The provider record.
263
+ */
264
+ export function createProvider(data: Record<string, unknown>) {
265
+ return {
266
+ type: CREATE_PROVIDER,
267
+ request: { op: 'post', path: '/@identity-providers', data },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Update a provider in place.
273
+ *
274
+ * @param providerId Provider to update.
275
+ * @param data The fields to change.
276
+ */
277
+ /**
278
+ * Move one of your addresses to the front of your profile's list.
279
+ *
280
+ * Which address stands for you is the order of that list -- the backend
281
+ * derives `email` from it, preferring the first verified one -- so choosing is
282
+ * a reorder rather than a field of its own. A `PATCH` on the profile is what
283
+ * saving the edit form does, and the same permission covers both.
284
+ *
285
+ * The whole list is sent, not just the address, because that is what the field
286
+ * is: sending one entry would replace the list with it.
287
+ *
288
+ * @param profilePath App-relative path of the profile.
289
+ * @param addresses The addresses in their new order.
290
+ */
291
+ export function setPreferredEmail(profilePath: string, addresses: string[]) {
292
+ return {
293
+ type: SET_PREFERRED_EMAIL,
294
+ request: {
295
+ op: 'patch',
296
+ path: profilePath,
297
+ data: { emails: addresses },
298
+ },
299
+ };
300
+ }
301
+
302
+ export function updateProvider(
303
+ providerId: string,
304
+ data: Record<string, unknown>,
305
+ ) {
306
+ return {
307
+ type: UPDATE_PROVIDER,
308
+ request: {
309
+ op: 'patch',
310
+ path: `/@identity-providers/${encodeURIComponent(providerId)}`,
311
+ data,
312
+ },
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Remove a provider.
318
+ *
319
+ * @param providerId Provider to remove.
320
+ */
321
+ export function deleteProvider(providerId: string) {
322
+ return {
323
+ type: DELETE_PROVIDER,
324
+ request: {
325
+ op: 'del',
326
+ path: `/@identity-providers/${encodeURIComponent(providerId)}`,
327
+ },
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Check that a provider can actually be reached.
333
+ *
334
+ * @param providerId Provider to check.
335
+ */
336
+ export function testProvider(providerId: string) {
337
+ return {
338
+ type: TEST_PROVIDER,
339
+ request: {
340
+ op: 'post',
341
+ path: `/@identity-providers/${encodeURIComponent(providerId)}/test-connection`,
342
+ data: {},
343
+ },
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Ask where the signed-in user's own Profile is, and how far along it is.
349
+ *
350
+ * Used for first-login routing: a user whose Profile is still `incomplete`
351
+ * should be asked to fill it in before being sent on their way. Answers
352
+ * usably for a user who has no Profile, so the caller does not have to know
353
+ * whether there is one.
354
+ */
355
+ export function getMyProfile() {
356
+ return {
357
+ type: GET_MY_PROFILE,
358
+ request: { op: 'get', path: '/@my-profile' },
359
+ };
360
+ }
361
+
362
+ /** Base path of the OAuth client registry. */
363
+ const CLIENTS = '/@identity-clients';
364
+
365
+ /** Base path of the signing key ring. */
366
+ const KEYS = '/@identity-keys';
367
+
368
+ /**
369
+ * List the OAuth clients registered against this site.
370
+ *
371
+ * The other direction from `listProviders`: those are who this site lets
372
+ * people log in *with*, these are who may log in *to* it.
373
+ */
374
+ export function listClients() {
375
+ return {
376
+ type: LIST_CLIENTS,
377
+ request: { op: 'get', path: CLIENTS },
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Register an OAuth client.
383
+ *
384
+ * The answer carries the client secret, and it is the only time it exists:
385
+ * the server stores a hash and cannot read it back. Whatever handles this
386
+ * result has to put it in front of the operator immediately.
387
+ *
388
+ * @param data The registration.
389
+ */
390
+ export function createClient(data: Record<string, unknown>) {
391
+ return {
392
+ type: CREATE_CLIENT,
393
+ request: { op: 'post', path: CLIENTS, data },
394
+ };
395
+ }
396
+
397
+ /**
398
+ * Amend a client registration.
399
+ *
400
+ * `client_id` and `auth_method` are not editable and the backend refuses
401
+ * them, along with any field it does not know: silently dropping one is how
402
+ * somebody comes to believe they changed something they did not.
403
+ *
404
+ * @param clientId The client to amend.
405
+ * @param data The fields to change.
406
+ */
407
+ export function updateClient(clientId: string, data: Record<string, unknown>) {
408
+ return {
409
+ type: UPDATE_CLIENT,
410
+ request: {
411
+ op: 'patch',
412
+ path: `${CLIENTS}/${encodeURIComponent(clientId)}`,
413
+ data,
414
+ },
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Unregister a client.
420
+ *
421
+ * Also a revocation: access tokens carry the client id as their audience and
422
+ * it is checked against the registry on every request, so this stops its
423
+ * tokens working at once.
424
+ *
425
+ * @param clientId The client to remove.
426
+ */
427
+ export function deleteClient(clientId: string) {
428
+ return {
429
+ type: DELETE_CLIENT,
430
+ request: { op: 'del', path: `${CLIENTS}/${encodeURIComponent(clientId)}` },
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Mint a client a fresh secret, discarding the old one.
436
+ *
437
+ * As with registration the answer carries the only copy.
438
+ *
439
+ * @param clientId The client to rotate.
440
+ */
441
+ export function rotateClientSecret(clientId: string) {
442
+ return {
443
+ type: ROTATE_CLIENT_SECRET,
444
+ request: {
445
+ op: 'post',
446
+ path: `${CLIENTS}/${encodeURIComponent(clientId)}/rotate-secret`,
447
+ data: {},
448
+ },
449
+ };
450
+ }
451
+
452
+ /** Describe the signing key ring. Metadata only; never key material. */
453
+ export function listKeys() {
454
+ return {
455
+ type: LIST_KEYS,
456
+ request: { op: 'get', path: KEYS },
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Rotate the signing key.
462
+ *
463
+ * Previous keys stay in the ring so tokens already issued keep verifying by
464
+ * `kid`. The ring is bounded, so rotating past that bound inside one
465
+ * access-token lifetime does invalidate tokens still in flight.
466
+ */
467
+ export function rotateKey() {
468
+ return {
469
+ type: ROTATE_KEY,
470
+ request: { op: 'post', path: `${KEYS}/rotate`, data: {} },
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Describe the authorization request the browser arrived with.
476
+ *
477
+ * Read-only, and deliberately so: this records no consent and issues no
478
+ * code. The answer goes back to the authorization endpoint, which decides
479
+ * again from scratch -- a client disabled between the question and the
480
+ * answer is refused on the way out as surely as on the way in.
481
+ *
482
+ * @param search The query string the consent route was reached with,
483
+ * including its leading `?`. Passed straight through rather than rebuilt
484
+ * from parsed parameters: an authorization request is compared byte for
485
+ * byte further down the flow, and a round trip through a parser is a
486
+ * chance to change it.
487
+ */
488
+ export function getConsentRequest(search: string) {
489
+ return {
490
+ type: GET_CONSENT_REQUEST,
491
+ request: { op: 'get', path: `/@oauth-consent${search}` },
492
+ };
493
+ }
494
+
495
+ /** Base path of the caller's own OAuth grants. */
496
+ const GRANTS = '/@oauth-grants';
497
+
498
+ /**
499
+ * List the applications the caller has authorized.
500
+ *
501
+ * The counterpart of `listIdentities`: that answers which providers they
502
+ * sign in with, this which applications they signed in to.
503
+ */
504
+ export function listGrants() {
505
+ return {
506
+ type: LIST_GRANTS,
507
+ request: { op: 'get', path: GRANTS },
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Withdraw the caller's agreement with one client.
513
+ *
514
+ * The backend forgets the agreement *and* revokes that client's refresh
515
+ * tokens for this user, because forgetting alone would only decide the next
516
+ * authorization. It answers with how many sessions ended and how long an
517
+ * access token already minted may still work, neither of which this can
518
+ * know on its own.
519
+ *
520
+ * @param clientId The client to withdraw from.
521
+ */
522
+ export function withdrawGrant(clientId: string) {
523
+ return {
524
+ type: WITHDRAW_GRANT,
525
+ request: {
526
+ op: 'del',
527
+ path: `${GRANTS}/${encodeURIComponent(clientId)}`,
528
+ },
529
+ };
530
+ }
File without changes
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Applications container: the authorized-applications page in user settings.
3
+ *
4
+ * Chrome and store, the way the identities page has them; the panel below
5
+ * does the rendering. This one adds the confirmation, because withdrawing is
6
+ * the destructive half and the person doing it should know what it costs
7
+ * before it happens rather than read about it afterwards.
8
+ * @module components/Applications/Applications
9
+ */
10
+ import React, { useCallback, useEffect, useState } from 'react';
11
+ import { useDispatch, useSelector } from 'react-redux';
12
+ import { Link, useLocation } from 'react-router-dom';
13
+ import { createPortal } from 'react-dom';
14
+ import { Container, Segment } from 'semantic-ui-react';
15
+ import { defineMessages, useIntl } from 'react-intl';
16
+ import { toast } from 'react-toastify';
17
+
18
+ import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
19
+ import { useClient } from '@plone/volto/hooks/client/useClient';
20
+ import Icon from '@plone/volto/components/theme/Icon/Icon';
21
+ import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
22
+ import Toast from '@plone/volto/components/manage/Toast/Toast';
23
+ import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
24
+ import backSVG from '@plone/volto/icons/back.svg';
25
+
26
+ import { listGrants, withdrawGrant } from '../../actions';
27
+ import ApplicationsPanel from './ApplicationsPanel';
28
+ import ConfirmModal from '../ControlPanel/ConfirmModal';
29
+
30
+ const messages = defineMessages({
31
+ title: { id: 'Applications', defaultMessage: 'Applications' },
32
+ back: { id: 'Back', defaultMessage: 'Back' },
33
+ confirm: {
34
+ id: 'Withdraw access for {client}?',
35
+ defaultMessage:
36
+ 'Withdraw access for {client}? It will be signed out everywhere and ' +
37
+ 'will have to ask you again next time.',
38
+ },
39
+ withdraw: { id: 'Withdraw access', defaultMessage: 'Withdraw access' },
40
+ withdrawn: {
41
+ id: 'Access withdrawn',
42
+ defaultMessage: 'Access withdrawn',
43
+ },
44
+ error: { id: 'Error', defaultMessage: 'Error' },
45
+ });
46
+
47
+ const Applications: React.FC = () => {
48
+ const intl = useIntl();
49
+ const dispatch = useDispatch();
50
+ const isClient = useClient();
51
+ const { pathname } = useLocation();
52
+ // Which client is being withdrawn, so only that row's button goes dead
53
+ // rather than the whole list.
54
+ const [withdrawing, setWithdrawing] = useState<string | null>(null);
55
+ // Which one's details are on screen. Held here rather than in the URL: a
56
+ // detail view is a way of reading one row of this page, not a page of its
57
+ // own, and a bookmark to somebody's authorization of one application is
58
+ // not a thing worth being able to share.
59
+ const [selected, setSelected] = useState<string | null>(null);
60
+
61
+ const grants = useSelector((state: any) => state.oauthGrants);
62
+
63
+ useEffect(() => {
64
+ dispatch(listGrants());
65
+ }, [dispatch]);
66
+
67
+ // Which application the question is being asked about, or null when it is
68
+ // not being asked. A blocking browser dialog was doing this, which froze
69
+ // the page and read as a different application than the one around it.
70
+ const [confirming, setConfirming] = useState<{
71
+ clientId: string;
72
+ title: string;
73
+ } | null>(null);
74
+
75
+ const onWithdraw = useCallback(
76
+ (clientId: string) => {
77
+ const grant = grants?.data?.items?.find(
78
+ (item: { client_id: string }) => item.client_id === clientId,
79
+ );
80
+ setConfirming({ clientId, title: grant?.title || clientId });
81
+ },
82
+ [grants],
83
+ );
84
+
85
+ const onConfirmWithdraw = useCallback(() => {
86
+ const asked = confirming;
87
+ setConfirming(null);
88
+ if (!asked) {
89
+ return;
90
+ }
91
+ const clientId = asked.clientId;
92
+ setWithdrawing(clientId);
93
+ (dispatch(withdrawGrant(clientId)) as any)
94
+ .then(() => {
95
+ toast.success(
96
+ <Toast success title={intl.formatMessage(messages.withdrawn)} />,
97
+ );
98
+ // Back to the list: the application whose details were on screen
99
+ // is the one that just stopped existing.
100
+ setSelected(null);
101
+ // Re-read rather than drop the row locally: the listing is what
102
+ // says whether anything is left, and a locally pruned one would
103
+ // disagree with the server the moment anything else changed.
104
+ dispatch(listGrants());
105
+ })
106
+ .catch((err: any) => {
107
+ toast.error(
108
+ <Toast
109
+ error
110
+ title={intl.formatMessage(messages.error)}
111
+ content={err?.response?.body?.error?.message ?? String(err)}
112
+ />,
113
+ );
114
+ })
115
+ .finally(() => setWithdrawing(null));
116
+ }, [confirming, dispatch, intl]);
117
+
118
+ return (
119
+ <Container id="page-applications">
120
+ <ConfirmModal
121
+ open={confirming !== null}
122
+ header={confirming?.title ?? ''}
123
+ content={intl.formatMessage(messages.confirm, {
124
+ client: confirming?.title ?? '',
125
+ })}
126
+ confirmLabel={intl.formatMessage(messages.withdraw)}
127
+ onCancel={() => setConfirming(null)}
128
+ onConfirm={onConfirmWithdraw}
129
+ />
130
+ <Helmet title={intl.formatMessage(messages.title)} />
131
+ <Segment.Group raised>
132
+ <Segment className="primary">
133
+ {intl.formatMessage(messages.title)}
134
+ </Segment>
135
+ <Segment>
136
+ <ApplicationsPanel
137
+ grants={grants?.data ?? null}
138
+ loading={Boolean(grants?.loading)}
139
+ error={grants?.error}
140
+ selected={selected}
141
+ withdrawing={withdrawing}
142
+ onSelect={setSelected}
143
+ onWithdraw={onWithdraw}
144
+ />
145
+ </Segment>
146
+ </Segment.Group>
147
+ {isClient &&
148
+ createPortal(
149
+ <Toolbar
150
+ pathname={pathname}
151
+ hideDefaultViewButtons
152
+ inner={
153
+ <Link to={`${getBaseUrl(pathname)}`} className="item">
154
+ <Icon
155
+ name={backSVG}
156
+ className="contents circled"
157
+ size="30px"
158
+ title={intl.formatMessage(messages.back)}
159
+ />
160
+ </Link>
161
+ }
162
+ />,
163
+ document.getElementById('toolbar') as HTMLElement,
164
+ )}
165
+ </Container>
166
+ );
167
+ };
168
+
169
+ export default Applications;