@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,600 @@
1
+ /**
2
+ * The providers control panel.
3
+ *
4
+ * Shaped after `volto-light-theme`'s Themes panel: a table of what exists,
5
+ * with the add and save actions living in the toolbar rather than inline, and
6
+ * the form itself rendered by Volto's own `Form` from a schema. Nothing here
7
+ * lays out an input; the driver describes its fields and Volto renders them,
8
+ * which is what keeps this panel looking like every other one.
9
+ * @module components/ControlPanel/ProvidersControlPanel
10
+ */
11
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
12
+ import { useDispatch, useSelector } from 'react-redux';
13
+ import { Link, useLocation } from 'react-router-dom';
14
+ import { createPortal } from 'react-dom';
15
+ import { Button, Container, Segment, Table } from 'semantic-ui-react';
16
+ import { defineMessages, useIntl } from 'react-intl';
17
+ import { toast } from 'react-toastify';
18
+
19
+ import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
20
+ import { useClient } from '@plone/volto/hooks/client/useClient';
21
+ import Icon from '@plone/volto/components/theme/Icon/Icon';
22
+ import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
23
+ import Toast from '@plone/volto/components/manage/Toast/Toast';
24
+ import { Form } from '@plone/volto/components/manage/Form';
25
+ import {
26
+ getControlpanel,
27
+ updateControlpanel,
28
+ } from '@plone/volto/actions/controlpanels/controlpanels';
29
+
30
+ import addSVG from '@plone/volto/icons/add.svg';
31
+ import backSVG from '@plone/volto/icons/back.svg';
32
+ import clearSVG from '@plone/volto/icons/clear.svg';
33
+ import deleteSVG from '@plone/volto/icons/delete.svg';
34
+ import pencilSVG from '@plone/volto/icons/pencil.svg';
35
+ import worldSVG from '@plone/volto/icons/world.svg';
36
+ import saveSVG from '@plone/volto/icons/save.svg';
37
+ import configurationSVG from '@plone/volto/icons/configuration.svg';
38
+
39
+ import {
40
+ createProvider,
41
+ deleteProvider,
42
+ listDrivers,
43
+ listProviders,
44
+ testProvider,
45
+ updateProvider,
46
+ } from '../../actions';
47
+
48
+ import {
49
+ CONFIG_PREFIX,
50
+ fromFormData,
51
+ providerSchema,
52
+ suggestedProviderId,
53
+ toFormData,
54
+ } from '../../helpers/providerSchema';
55
+ import type { ConfiguredProvider, Driver } from '../../types';
56
+
57
+ import './ProvidersControlPanel.scss';
58
+ import ConfirmModal from './ConfirmModal';
59
+
60
+ /**
61
+ * The configlet id, which is also the name the site-wide settings are served
62
+ * under at `@controlpanels/<id>`.
63
+ */
64
+ const CONFIGLET_ID = 'identity-providers';
65
+
66
+ const messages = defineMessages({
67
+ title: { id: 'Identity providers', defaultMessage: 'Identity providers' },
68
+ add: { id: 'Add provider', defaultMessage: 'Add provider' },
69
+ back: { id: 'Back', defaultMessage: 'Back' },
70
+ save: { id: 'Save', defaultMessage: 'Save' },
71
+ cancel: { id: 'Cancel', defaultMessage: 'Cancel' },
72
+ edit: { id: 'Edit', defaultMessage: 'Edit' },
73
+ test: { id: 'Test connection', defaultMessage: 'Test connection' },
74
+ delete: { id: 'Delete', defaultMessage: 'Delete' },
75
+ saved: { id: 'Changes saved', defaultMessage: 'Changes saved' },
76
+ deleted: { id: 'Provider deleted', defaultMessage: 'Provider deleted' },
77
+ settings: { id: 'Settings', defaultMessage: 'Settings' },
78
+ settingsUnavailable: {
79
+ id: 'The site settings could not be read',
80
+ defaultMessage:
81
+ 'The site settings could not be read, so they cannot be edited here. ' +
82
+ 'The most likely cause is a settings field with no registry record, ' +
83
+ 'which happens when the add-on gained one and its profile has not ' +
84
+ 'been reapplied since.',
85
+ },
86
+ noCallback: {
87
+ id: 'No login callback URL is configured',
88
+ defaultMessage:
89
+ 'No login callback URL is configured, so no provider can complete a ' +
90
+ 'sign-in. Set it under Settings.',
91
+ },
92
+ error: { id: 'Error', defaultMessage: 'Error' },
93
+ empty: {
94
+ id: 'No providers are configured yet.',
95
+ defaultMessage: 'No providers are configured yet.',
96
+ },
97
+ noDrivers: {
98
+ id: 'No drivers are installed.',
99
+ defaultMessage:
100
+ 'No drivers are installed, so there is nothing to configure. Install ' +
101
+ 'an add-on that registers one.',
102
+ },
103
+ columnTitle: { id: 'Title', defaultMessage: 'Title' },
104
+ columnId: { id: 'Id', defaultMessage: 'Id' },
105
+ columnDriver: { id: 'Driver', defaultMessage: 'Driver' },
106
+ columnEnabled: { id: 'Enabled', defaultMessage: 'Enabled' },
107
+ columnActions: { id: 'Actions', defaultMessage: 'Actions' },
108
+ yes: { id: 'Yes', defaultMessage: 'Yes' },
109
+ no: { id: 'No', defaultMessage: 'No' },
110
+ reached: {
111
+ id: 'Reached {endpoint}',
112
+ defaultMessage: 'Reached {endpoint}',
113
+ },
114
+ reachedNoJwks: {
115
+ id: 'Reached {endpoint} (no key set published)',
116
+ defaultMessage: 'Reached {endpoint} (no key set published)',
117
+ },
118
+ confirmDelete: {
119
+ id: 'Delete this provider?',
120
+ defaultMessage:
121
+ 'Delete this provider? Identities already linked through it keep the ' +
122
+ 'stored id and stop resolving.',
123
+ },
124
+ });
125
+
126
+ const ProvidersControlPanel: React.FC = () => {
127
+ const intl = useIntl();
128
+ const dispatch = useDispatch();
129
+ const isClient = useClient();
130
+ const { pathname } = useLocation();
131
+ const formRef = useRef<any>(null);
132
+
133
+ const [editing, setEditing] = useState<string | null>(null);
134
+ const [adding, setAdding] = useState(false);
135
+ const [editingSettings, setEditingSettings] = useState(false);
136
+ // Which driver the add form is currently on. The schema depends on it, so
137
+ // it is tracked as the form changes rather than read at submit time.
138
+ const [draftDriver, setDraftDriver] = useState<string | undefined>(undefined);
139
+ // Whether the operator has written a provider id of their own. Until they
140
+ // do, choosing a driver names the provider after it; once they have, a
141
+ // later driver change must not overwrite what they typed.
142
+ const [idTouched, setIdTouched] = useState(false);
143
+ // The live form state. A ref rather than state on purpose: it changes on
144
+ // every keystroke and nothing here needs to re-render for that. It is read
145
+ // once, when the driver changes, to carry what has already been typed
146
+ // across the remount the new schema needs.
147
+ const draft = useRef<Record<string, unknown>>({});
148
+ const [error, setError] = useState<unknown>(null);
149
+
150
+ const providers = useSelector((state: any) => state.configuredProviders) as {
151
+ data?: ConfiguredProvider[];
152
+ loading?: boolean;
153
+ };
154
+ const drivers = useSelector((state: any) => state.identityDrivers) as {
155
+ data?: Driver[];
156
+ };
157
+ const check = useSelector((state: any) => state.providerTest);
158
+ // The provider's own fields, serialized by the backend from the interface
159
+ // its registry records are bound to. The driver's half rides on each entry
160
+ // of `identityDrivers`.
161
+ const servedSchema = useSelector(
162
+ (state: any) => state.providerFormSchema?.data,
163
+ );
164
+ // The site-wide settings, served by the same configlet id. The callback
165
+ // URL lives here rather than on a provider: it is one route in the
166
+ // frontend, registered identically with every provider.
167
+ const settings = useSelector(
168
+ (state: any) => state.controlpanels?.controlpanel,
169
+ );
170
+ // Its request state separately: the reducer clears `controlpanel` to null
171
+ // while pending *and* on failure, so the value alone cannot tell "still
172
+ // loading" from "the backend refused".
173
+ const settingsRequest = useSelector((state: any) => state.controlpanels?.get);
174
+
175
+ const items = providers?.data ?? [];
176
+ // Memoized because the schema is built from it: a fresh [] on every render
177
+ // would rebuild the schema on every render, and Form would lose its state.
178
+ const driverList = useMemo(() => drivers?.data ?? [], [drivers?.data]);
179
+
180
+ const refresh = () => {
181
+ dispatch(listProviders());
182
+ };
183
+
184
+ useEffect(() => {
185
+ dispatch(listProviders());
186
+ dispatch(listDrivers());
187
+ dispatch(getControlpanel(CONFIGLET_ID));
188
+ }, [dispatch]);
189
+
190
+ useEffect(() => {
191
+ if (check?.loaded && check?.data) {
192
+ const result = check.data;
193
+ if (result.ok) {
194
+ toast.success(
195
+ <Toast
196
+ success
197
+ title={intl.formatMessage(messages.test)}
198
+ content={intl.formatMessage(
199
+ result.has_jwks ? messages.reached : messages.reachedNoJwks,
200
+ { endpoint: result.token_endpoint },
201
+ )}
202
+ />,
203
+ );
204
+ } else {
205
+ toast.error(
206
+ <Toast
207
+ error
208
+ title={intl.formatMessage(messages.test)}
209
+ content={result.error}
210
+ />,
211
+ );
212
+ }
213
+ }
214
+ }, [check?.loaded, check?.data, intl]);
215
+
216
+ const fail = (err: any) => {
217
+ setError(err);
218
+ toast.error(
219
+ <Toast
220
+ error
221
+ title={intl.formatMessage(messages.error)}
222
+ content={err?.response?.body?.error?.message ?? String(err)}
223
+ />,
224
+ );
225
+ };
226
+
227
+ const succeed = (message: string) => {
228
+ toast.success(<Toast success title={message} />);
229
+ closeForm();
230
+ refresh();
231
+ };
232
+
233
+ function closeForm() {
234
+ setAdding(false);
235
+ setEditing(null);
236
+ setEditingSettings(false);
237
+ setDraftDriver(undefined);
238
+ setIdTouched(false);
239
+ setError(null);
240
+ }
241
+
242
+ const current = editing
243
+ ? items.find((provider) => provider.id === editing)
244
+ : undefined;
245
+ const isForm = adding || editing !== null || editingSettings;
246
+ const callbackUrl = settings?.data?.callback_url;
247
+ // Volto's Form reads schema.fieldsets on the first render, so opening the
248
+ // settings without one is a crash rather than an empty form.
249
+ const settingsReady = Boolean(settings?.schema);
250
+
251
+ const schema = useMemo(
252
+ () =>
253
+ providerSchema(
254
+ servedSchema,
255
+ driverList,
256
+ adding ? draftDriver : current?.driver,
257
+ adding,
258
+ intl,
259
+ ),
260
+ [servedSchema, driverList, adding, draftDriver, current?.driver, intl],
261
+ );
262
+
263
+ // Not memoized: `Form` reads this once, when it mounts, so recomputing it
264
+ // on a render it will ignore costs nothing.
265
+ //
266
+ // For the add form it is whatever has been typed so far, minus the previous
267
+ // driver's settings -- those mean nothing to the driver just chosen. What
268
+ // replaces them is `Form`'s own job: it seeds an add form from the schema's
269
+ // defaults, which is how the driver's sane values reach the fields.
270
+ const formData = adding
271
+ ? {
272
+ ...toFormData(undefined, {
273
+ propertymap: driverList.find((d) => d.id === draftDriver)
274
+ ?.default_propertymap,
275
+ groupmap: driverList.find((d) => d.id === draftDriver)
276
+ ?.default_groupmap,
277
+ }),
278
+ ...Object.fromEntries(
279
+ Object.entries(draft.current).filter(
280
+ // The mapping goes the way the settings do when the driver
281
+ // changes: it is written in the claim names of the driver that
282
+ // was chosen, so carrying it over would keep a map for a
283
+ // provider that no longer exists.
284
+ ([key]) =>
285
+ !key.startsWith(CONFIG_PREFIX) &&
286
+ key !== 'propertymap' &&
287
+ key !== 'groupmap',
288
+ ),
289
+ ),
290
+ // Read on the remount a driver change causes, so the suggestion
291
+ // follows the driver for as long as the operator lets it.
292
+ ...(idTouched
293
+ ? {}
294
+ : { id: suggestedProviderId(driverList, draftDriver) }),
295
+ }
296
+ : toFormData(current);
297
+
298
+ const onSubmit = (data: Record<string, unknown>) => {
299
+ const payload = fromFormData(data);
300
+ if (adding) {
301
+ (dispatch(createProvider(payload)) as any)
302
+ .then(() => succeed(intl.formatMessage(messages.saved)))
303
+ .catch(fail);
304
+ return;
305
+ }
306
+ const { id: _id, ...rest } = payload;
307
+ (dispatch(updateProvider(editing as string, rest)) as any)
308
+ .then(() => succeed(intl.formatMessage(messages.saved)))
309
+ .catch(fail);
310
+ };
311
+
312
+ const onSaveSettings = (data: Record<string, unknown>) => {
313
+ const { '@id': _atId, ...values } = data ?? {};
314
+ (dispatch(updateControlpanel(settings['@id'], values)) as any)
315
+ .then(() => {
316
+ succeed(intl.formatMessage(messages.saved));
317
+ dispatch(getControlpanel(CONFIGLET_ID));
318
+ })
319
+ .catch(fail);
320
+ };
321
+
322
+ // Which provider the question is being asked about, or null when it is
323
+ // not being asked. State rather than a blocking call: the panel stays
324
+ // interactive and the dialog is part of the page.
325
+ const [confirming, setConfirming] = React.useState<ConfiguredProvider | null>(
326
+ null,
327
+ );
328
+
329
+ const onDelete = (provider: ConfiguredProvider) => {
330
+ setConfirming(provider);
331
+ };
332
+
333
+ const onConfirmDelete = () => {
334
+ const provider = confirming;
335
+ setConfirming(null);
336
+ if (!provider) {
337
+ return;
338
+ }
339
+ (dispatch(deleteProvider(provider.id)) as any)
340
+ .then(() => succeed(intl.formatMessage(messages.deleted)))
341
+ .catch(fail);
342
+ };
343
+
344
+ return (
345
+ <div id="page-controlpanel" className="identity-controlpanel">
346
+ <ConfirmModal
347
+ open={confirming !== null}
348
+ header={confirming?.title || confirming?.id || ''}
349
+ content={intl.formatMessage(messages.confirmDelete)}
350
+ onCancel={() => setConfirming(null)}
351
+ onConfirm={onConfirmDelete}
352
+ />
353
+ <Helmet title={intl.formatMessage(messages.title)} />
354
+ <Container>
355
+ {editingSettings && !settingsReady ? (
356
+ <Segment.Group raised>
357
+ <Segment className="primary">
358
+ {intl.formatMessage(messages.settings)}
359
+ </Segment>
360
+ <Segment>
361
+ <p role="alert" className="identity-error">
362
+ {intl.formatMessage(messages.settingsUnavailable)}
363
+ </p>
364
+ {settingsRequest?.error ? (
365
+ <pre className="identity-controlpanel__detail">
366
+ {settingsRequest.error?.response?.body?.message ??
367
+ String(settingsRequest.error)}
368
+ </pre>
369
+ ) : null}
370
+ </Segment>
371
+ </Segment.Group>
372
+ ) : isForm ? (
373
+ <Form
374
+ ref={formRef}
375
+ // A new driver is a new set of fields, and `Form` seeds an add
376
+ // form's defaults in its constructor -- once, from the schema it
377
+ // mounted with. Without a remount the fields a driver declares
378
+ // appear empty however good its defaults are, because the only
379
+ // moment they would have been applied is already past.
380
+ key={
381
+ adding ? `add-${draftDriver ?? 'none'}` : editing ?? 'settings'
382
+ }
383
+ title={
384
+ editingSettings
385
+ ? intl.formatMessage(messages.settings)
386
+ : adding
387
+ ? intl.formatMessage(messages.add)
388
+ : current?.title || current?.id
389
+ }
390
+ // The settings schema comes from the backend, which already
391
+ // serves it for the Classic form; nothing is described twice.
392
+ schema={editingSettings ? settings?.schema : schema}
393
+ formData={editingSettings ? settings?.data : formData}
394
+ requestError={error}
395
+ onSubmit={editingSettings ? onSaveSettings : onSubmit}
396
+ onCancel={closeForm}
397
+ onChangeFormData={(data: Record<string, unknown>) => {
398
+ draft.current = data;
399
+ // The driver decides which settings exist, so choosing one has
400
+ // to rebuild the schema rather than wait for submit.
401
+ if (adding && data.driver !== draftDriver) {
402
+ setDraftDriver(data.driver as string);
403
+ }
404
+ // Compared against the suggestion for the driver the form is
405
+ // still on: a driver change arrives here with the previous
406
+ // driver's id, which is the suggestion and not a typed one.
407
+ if (
408
+ adding &&
409
+ !idTouched &&
410
+ data.id !== suggestedProviderId(driverList, draftDriver)
411
+ ) {
412
+ setIdTouched(true);
413
+ }
414
+ }}
415
+ hideActions
416
+ />
417
+ ) : (
418
+ <Segment.Group raised>
419
+ <Segment className="primary">
420
+ {intl.formatMessage(messages.title)}
421
+ </Segment>
422
+ {settings?.data && !callbackUrl ? (
423
+ // Without it every sign-in fails at the last step with an
424
+ // error only the log shows. Better said here, before anyone
425
+ // configures a provider and wonders why it does not work.
426
+ <Segment className="identity-controlpanel__warning" secondary>
427
+ <strong>{intl.formatMessage(messages.noCallback)}</strong>
428
+ </Segment>
429
+ ) : null}
430
+ <Segment>
431
+ {items.length ? (
432
+ <Table selectable compact>
433
+ <Table.Header>
434
+ <Table.Row>
435
+ <Table.HeaderCell>
436
+ {intl.formatMessage(messages.columnTitle)}
437
+ </Table.HeaderCell>
438
+ <Table.HeaderCell>
439
+ {intl.formatMessage(messages.columnId)}
440
+ </Table.HeaderCell>
441
+ <Table.HeaderCell>
442
+ {intl.formatMessage(messages.columnDriver)}
443
+ </Table.HeaderCell>
444
+ <Table.HeaderCell>
445
+ {intl.formatMessage(messages.columnEnabled)}
446
+ </Table.HeaderCell>
447
+ <Table.HeaderCell textAlign="right">
448
+ {intl.formatMessage(messages.columnActions)}
449
+ </Table.HeaderCell>
450
+ </Table.Row>
451
+ </Table.Header>
452
+ <Table.Body>
453
+ {items.map((provider) => (
454
+ <Table.Row
455
+ key={provider['@id']}
456
+ data-provider={provider.id}
457
+ >
458
+ <Table.Cell>{provider.title || provider.id}</Table.Cell>
459
+ <Table.Cell>
460
+ <code>{provider.id}</code>
461
+ </Table.Cell>
462
+ <Table.Cell>{provider.driver}</Table.Cell>
463
+ <Table.Cell>
464
+ {intl.formatMessage(
465
+ provider.enabled ? messages.yes : messages.no,
466
+ )}
467
+ </Table.Cell>
468
+ <Table.Cell textAlign="right">
469
+ <Button
470
+ basic
471
+ icon
472
+ aria-label={intl.formatMessage(messages.edit)}
473
+ title={intl.formatMessage(messages.edit)}
474
+ onClick={() => setEditing(provider.id)}
475
+ >
476
+ <Icon name={pencilSVG} size="20px" />
477
+ </Button>
478
+ <Button
479
+ basic
480
+ icon
481
+ aria-label={intl.formatMessage(messages.test)}
482
+ title={intl.formatMessage(messages.test)}
483
+ onClick={() => dispatch(testProvider(provider.id))}
484
+ >
485
+ <Icon name={worldSVG} size="20px" />
486
+ </Button>
487
+ <Button
488
+ basic
489
+ icon
490
+ data-action="delete"
491
+ aria-label={intl.formatMessage(messages.delete)}
492
+ title={intl.formatMessage(messages.delete)}
493
+ onClick={() => onDelete(provider)}
494
+ >
495
+ <Icon name={deleteSVG} size="20px" />
496
+ </Button>
497
+ </Table.Cell>
498
+ </Table.Row>
499
+ ))}
500
+ </Table.Body>
501
+ </Table>
502
+ ) : (
503
+ <p className="identity-controlpanel__empty identity-note">
504
+ {intl.formatMessage(
505
+ driverList.length ? messages.empty : messages.noDrivers,
506
+ )}
507
+ </p>
508
+ )}
509
+ </Segment>
510
+ </Segment.Group>
511
+ )}
512
+ </Container>
513
+ {isClient &&
514
+ createPortal(
515
+ <Toolbar
516
+ pathname={pathname}
517
+ hideDefaultViewButtons
518
+ inner={
519
+ isForm ? (
520
+ <>
521
+ {/* No Save for a form that is not there; Cancel still is,
522
+ so the error view is not a dead end. */}
523
+ {editingSettings && !settingsReady ? null : (
524
+ <Button
525
+ id="toolbar-save"
526
+ className="save"
527
+ aria-label={intl.formatMessage(messages.save)}
528
+ onClick={() => formRef.current?.onSubmit()}
529
+ >
530
+ <Icon
531
+ name={saveSVG}
532
+ className="circled"
533
+ size="30px"
534
+ title={intl.formatMessage(messages.save)}
535
+ />
536
+ </Button>
537
+ )}
538
+ <Button
539
+ className="cancel"
540
+ aria-label={intl.formatMessage(messages.cancel)}
541
+ onClick={closeForm}
542
+ >
543
+ <Icon
544
+ name={clearSVG}
545
+ className="circled"
546
+ size="30px"
547
+ title={intl.formatMessage(messages.cancel)}
548
+ />
549
+ </Button>
550
+ </>
551
+ ) : (
552
+ <>
553
+ <Button
554
+ id="toolbar-settings"
555
+ aria-label={intl.formatMessage(messages.settings)}
556
+ onClick={() => setEditingSettings(true)}
557
+ >
558
+ <Icon
559
+ name={configurationSVG}
560
+ className="circled"
561
+ size="30px"
562
+ title={intl.formatMessage(messages.settings)}
563
+ />
564
+ </Button>
565
+ {driverList.length ? (
566
+ <Button
567
+ id="toolbar-add"
568
+ aria-label={intl.formatMessage(messages.add)}
569
+ onClick={() => setAdding(true)}
570
+ >
571
+ <Icon
572
+ name={addSVG}
573
+ className="circled"
574
+ size="30px"
575
+ title={intl.formatMessage(messages.add)}
576
+ />
577
+ </Button>
578
+ ) : null}
579
+ {/* A router link, not an anchor: an `href` here left the
580
+ toolbar's back button reloading the whole application
581
+ to reach a route Volto already has. */}
582
+ <Link className="item" to="/controlpanel">
583
+ <Icon
584
+ name={backSVG}
585
+ className="circled"
586
+ size="30px"
587
+ title={intl.formatMessage(messages.back)}
588
+ />
589
+ </Link>
590
+ </>
591
+ )
592
+ }
593
+ />,
594
+ document.getElementById('toolbar') as HTMLElement,
595
+ )}
596
+ </div>
597
+ );
598
+ };
599
+
600
+ export default ProvidersControlPanel;