@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,362 @@
1
+ /**
2
+ * The provider form, composed from what the backend already sent.
3
+ *
4
+ * This file used to *build* the schema: 529 lines turning a hand-written
5
+ * descriptor dict — `{type, title, required, secret, order}` — into Volto
6
+ * properties, with its own notion of field types, its own ordering by a
7
+ * spaced-out `order` key, its own secret flag, and its own untranslated
8
+ * English strings. Érico's verdict (2026-08-29) was that it made a Classic UI
9
+ * form impossible and reinvented, worse, what Plone already had.
10
+ *
11
+ * So the backend now serves two ordinary JSON schemas, produced by
12
+ * `plone.restapi` from two interfaces:
13
+ *
14
+ * - `@identity-providers` sends the provider's own fields, from
15
+ * `IProviderRecords` — the same interface its registry records are bound
16
+ * to, so the form and the storage cannot describe different things.
17
+ * - `@identity-drivers` sends each driver's settings, from its
18
+ * `settings_schema`.
19
+ *
20
+ * What is left here is composition, and only composition: merge the two,
21
+ * prefix the driver's half so a flat form can carry a nested object, and add
22
+ * the two fields that exist only while a provider is being created. Nothing
23
+ * below decides what a field looks like — that is the backend's answer, in
24
+ * the site's language, and this file must never start having an opinion about
25
+ * it again.
26
+ * @module helpers/providerSchema
27
+ */
28
+ import { defineMessages } from 'react-intl';
29
+
30
+ import { toGroupRows, fromGroupRows } from './groupmap';
31
+ import { toRows, fromRows } from './propertymap';
32
+ import type { Driver, JsonSchema, VoltoSchema } from '../types';
33
+
34
+ import type { IntlShape } from 'react-intl';
35
+
36
+ /**
37
+ * Prefix a driver setting carries in form data.
38
+ *
39
+ * The API nests them under `config`, and a Volto form is flat. The prefix is
40
+ * what lets one flat object round-trip both halves without the two colliding
41
+ * — a driver may perfectly well declare a `title`, and it is not the
42
+ * provider's.
43
+ */
44
+ export const CONFIG_PREFIX = 'config.';
45
+
46
+ /** Fieldset the driver's own settings are rendered in. */
47
+ export const SETTINGS_FIELDSET = 'settings';
48
+
49
+ /** Fieldset the two claim mappings are rendered in. */
50
+ export const MAPPING_FIELDSET = 'mapping';
51
+
52
+ /**
53
+ * Fields `IProviderRecords` declares that this file renders itself.
54
+ *
55
+ * The served schema describes a provider's *storage*, which is right, and
56
+ * three of its fields are not renderable as described. `driver` is a picker
57
+ * over the registered drivers and is asked only while adding -- a provider's
58
+ * driver is not an editable property, and the served `TextLine` would win,
59
+ * because the merge below arrives after the picker is put in. `propertymap`
60
+ * and `groupmap` are `Dict` fields, which Volto has no widget for at all;
61
+ * they are rebuilt as row editors in a fieldset of their own. So the served
62
+ * descriptions of the three are dropped rather than merged.
63
+ */
64
+ const COMPOSED_HERE = ['driver', 'propertymap', 'groupmap'];
65
+
66
+ const messages = defineMessages({
67
+ identity: { id: 'Identity', defaultMessage: 'Identity' },
68
+ driver: { id: 'Driver', defaultMessage: 'Driver' },
69
+ driverHelp: {
70
+ id: 'provider-driver-help',
71
+ defaultMessage:
72
+ 'Which service this provider talks to. It decides the rest of the ' +
73
+ 'form, and it cannot be changed afterwards.',
74
+ },
75
+ providerId: { id: 'Provider id', defaultMessage: 'Provider id' },
76
+ providerIdHelp: {
77
+ id: 'provider-id-help',
78
+ defaultMessage:
79
+ 'Used in URLs and stored with every identity linked through this ' +
80
+ 'provider, so it is permanent.',
81
+ },
82
+ settings: { id: 'Settings', defaultMessage: 'Settings' },
83
+ mapping: { id: 'Mapping', defaultMessage: 'Mapping' },
84
+ propertymap: { id: 'Property map', defaultMessage: 'Property map' },
85
+ groupmap: { id: 'Group map', defaultMessage: 'Group map' },
86
+ });
87
+
88
+ /**
89
+ * Suggest an id for a provider being added.
90
+ *
91
+ * Slugified from the driver's title, which reads better than its id for the
92
+ * one thing that becomes permanent the moment it is saved. Unchanged by the
93
+ * schema rewrite: it is about naming a provider, not about describing a
94
+ * field.
95
+ *
96
+ * @param drivers Every registered driver.
97
+ * @param driverId The driver just chosen.
98
+ * @returns A suggested id, or an empty string.
99
+ */
100
+ export function suggestedProviderId(
101
+ drivers: Driver[],
102
+ driverId: string | undefined,
103
+ ): string {
104
+ const driver = drivers.find((d) => d.id === driverId);
105
+ if (!driver) {
106
+ return '';
107
+ }
108
+ return (
109
+ driver.title
110
+ .toLowerCase()
111
+ // The id accepts letters, digits, - and _ only, so everything else
112
+ // becomes a separator rather than being dropped -- "Sign in (beta)"
113
+ // should read as sign-in-beta, not signinbeta.
114
+ .replace(/[^a-z0-9_]+/g, '-')
115
+ .replace(/^-+|-+$/g, '') || driver.id
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Build the provider form from the two schemas the backend sent.
121
+ *
122
+ * @param served The provider's own schema, from `@identity-providers`.
123
+ * @param drivers Every registered driver, each carrying its settings schema.
124
+ * @param driverId The driver whose settings to render.
125
+ * @param adding Whether this is the add form, which asks two more questions.
126
+ * @param intl For the labels of the fields that exist only in this form.
127
+ * @returns A Volto schema.
128
+ */
129
+ export function providerSchema(
130
+ served: JsonSchema | undefined,
131
+ drivers: Driver[],
132
+ driverId: string | undefined,
133
+ adding: boolean,
134
+ intl: IntlShape,
135
+ ): VoltoSchema {
136
+ const properties: Record<string, Record<string, unknown>> = {};
137
+ const fieldsets: { id: string; title: string; fields: string[] }[] = [];
138
+ const required: string[] = (served?.required ?? []).filter(
139
+ (name) => !COMPOSED_HERE.includes(name),
140
+ );
141
+
142
+ if (adding) {
143
+ // The only two fields this file still invents, and they are not stored
144
+ // anywhere: the driver decides which schema the rest of the form uses,
145
+ // and the id becomes the record prefix. Both are answered once, at
146
+ // creation, and neither exists on a provider that already exists.
147
+ properties.driver = {
148
+ title: intl.formatMessage(messages.driver),
149
+ description: intl.formatMessage(messages.driverHelp),
150
+ choices: drivers.map((d) => [d.id, d.title]),
151
+ };
152
+ properties.id = {
153
+ title: intl.formatMessage(messages.providerId),
154
+ description: intl.formatMessage(messages.providerIdHelp),
155
+ type: 'string',
156
+ };
157
+ required.push('driver', 'id');
158
+ }
159
+
160
+ Object.assign(
161
+ properties,
162
+ Object.fromEntries(
163
+ Object.entries(served?.properties ?? {}).filter(
164
+ ([name]) => !COMPOSED_HERE.includes(name),
165
+ ),
166
+ ),
167
+ );
168
+
169
+ // The backend's own fieldsets, in its order, with the creation questions
170
+ // folded into the first one so they are the first thing asked.
171
+ (served?.fieldsets ?? []).forEach((fieldset, index) => {
172
+ const extra = adding && index === 0 ? ['driver', 'id'] : [];
173
+ const fields = [
174
+ ...extra,
175
+ ...fieldset.fields.filter((name) => !COMPOSED_HERE.includes(name)),
176
+ ];
177
+ // A fieldset the filter empties is not rendered as an empty tab.
178
+ if (!fields.length) return;
179
+ fieldsets.push({
180
+ id: fieldset.id,
181
+ title:
182
+ index === 0 ? intl.formatMessage(messages.identity) : fieldset.title,
183
+ fields,
184
+ });
185
+ });
186
+
187
+ const driver = drivers.find((d) => d.id === driverId);
188
+ const settings: JsonSchema = driver?.schema ?? {};
189
+ const settingFields: string[] = [];
190
+ for (const [name, property] of Object.entries(settings.properties ?? {})) {
191
+ const key = `${CONFIG_PREFIX}${name}`;
192
+ properties[key] = property;
193
+ settingFields.push(key);
194
+ if ((settings.required ?? []).includes(name)) {
195
+ required.push(key);
196
+ }
197
+ }
198
+ // The driver's own fieldsets, kept rather than flattened. A driver groups
199
+ // its settings for a reason -- how to reach the provider, what to do with
200
+ // the person it returns, how its groups map -- and collapsing that into one
201
+ // Settings tab put a dozen unrelated fields in a single column.
202
+ //
203
+ // The driver's `default` fieldset is the one it did not name, so it takes
204
+ // this form's own Settings label; the rest keep the titles the backend
205
+ // translated. A driver that declares no fieldsets at all still gets exactly
206
+ // one tab, which is what every driver did before this.
207
+ const driverFieldsets = settings.fieldsets ?? [];
208
+ if (settingFields.length && driverFieldsets.length) {
209
+ driverFieldsets.forEach((driverFieldset) => {
210
+ const fields = driverFieldset.fields.map(
211
+ (name) => `${CONFIG_PREFIX}${name}`,
212
+ );
213
+ if (!fields.length) return;
214
+ fieldsets.push({
215
+ id:
216
+ driverFieldset.id === 'default'
217
+ ? SETTINGS_FIELDSET
218
+ : `${SETTINGS_FIELDSET}-${driverFieldset.id}`,
219
+ title:
220
+ driverFieldset.id === 'default'
221
+ ? intl.formatMessage(messages.settings)
222
+ : driverFieldset.title,
223
+ fields,
224
+ });
225
+ });
226
+ } else if (settingFields.length) {
227
+ fieldsets.push({
228
+ id: SETTINGS_FIELDSET,
229
+ title: intl.formatMessage(messages.settings),
230
+ fields: settingFields,
231
+ });
232
+ }
233
+
234
+ // The two mappings. Their *shape* is `Dict` on the backend; Volto has no
235
+ // Dict widget, so they are edited as rows and converted below. That is a
236
+ // widget gap rather than a schema this file invented.
237
+ properties.propertymap = {
238
+ title: intl.formatMessage(messages.propertymap),
239
+ widget: 'object_list',
240
+ schema: rowSchema(intl, 'claim', 'field'),
241
+ };
242
+ // The group map, only for a driver whose providers have groups. The
243
+ // backend declares that by putting a `group_claim` field in the settings
244
+ // schema, and it is the same switch on both ends: a driver with no groups
245
+ // offers no claim to read them from, and a map stored against one grants
246
+ // nothing. Asking an operator to map the groups of a magic link would be
247
+ // asking a question with no answer.
248
+ const hasGroups = Boolean(settings.properties?.group_claim);
249
+ if (hasGroups) {
250
+ properties.groupmap = {
251
+ title: intl.formatMessage(messages.groupmap),
252
+ widget: 'object_list',
253
+ schema: rowSchema(intl, 'group', 'local'),
254
+ };
255
+ }
256
+ fieldsets.push({
257
+ id: MAPPING_FIELDSET,
258
+ title: intl.formatMessage(messages.mapping),
259
+ fields: hasGroups ? ['propertymap', 'groupmap'] : ['propertymap'],
260
+ });
261
+
262
+ return { fieldsets, properties, required };
263
+ }
264
+
265
+ /**
266
+ * The two-column schema a mapping row is edited with.
267
+ *
268
+ * @param intl For the column labels.
269
+ * @param left Name of the left-hand field.
270
+ * @param right Name of the right-hand field.
271
+ * @returns A Volto schema for one row.
272
+ */
273
+ function rowSchema(intl: IntlShape, left: string, right: string): VoltoSchema {
274
+ return {
275
+ fieldsets: [{ id: 'default', title: 'default', fields: [left, right] }],
276
+ properties: {
277
+ [left]: { title: left, type: 'string' },
278
+ [right]: { title: right, type: 'string' },
279
+ },
280
+ required: [],
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Turn a provider from the API into form data.
286
+ *
287
+ * @param provider The provider, or undefined when adding.
288
+ * @param defaults Seeds for a provider being added.
289
+ * @returns Flat form data.
290
+ */
291
+ export function toFormData(
292
+ provider: Record<string, any> | undefined,
293
+ defaults?: {
294
+ propertymap?: Record<string, string>;
295
+ groupmap?: Record<string, string>;
296
+ },
297
+ ): Record<string, unknown> {
298
+ if (!provider) {
299
+ return {
300
+ enabled: true,
301
+ show_in_login: true,
302
+ propertymap: toRows(defaults?.propertymap),
303
+ groupmap: toGroupRows(defaults?.groupmap),
304
+ };
305
+ }
306
+ const data: Record<string, unknown> = {
307
+ title: provider.title ?? '',
308
+ enabled: provider.enabled ?? true,
309
+ // Absent means shown. A provider stored before the setting existed was
310
+ // on the login page, and reading the key back as false would take a
311
+ // site's login buttons away on upgrade.
312
+ show_in_login: provider.show_in_login ?? true,
313
+ // The upload envelope the backend sends, handed to the widget unchanged.
314
+ icon: provider.icon ?? '',
315
+ background_color: provider.background_color ?? '',
316
+ foreground_color: provider.foreground_color ?? '',
317
+ propertymap: toRows(provider.propertymap),
318
+ groupmap: toGroupRows(provider.groupmap),
319
+ };
320
+ for (const [name, value] of Object.entries(provider.config ?? {})) {
321
+ data[`${CONFIG_PREFIX}${name}`] = value;
322
+ }
323
+ return data;
324
+ }
325
+
326
+ /**
327
+ * Turn submitted form data back into the API payload.
328
+ *
329
+ * Only shape, never validation. Every rule about what a value may be lives on
330
+ * the backend schema now — a colour it cannot parse, an icon that is not SVG,
331
+ * a redirect URI a browser could execute — so trimming here would only hide
332
+ * which value the refusal is about.
333
+ *
334
+ * @param formData What the form submitted.
335
+ * @returns The body for POST or PATCH.
336
+ */
337
+ export function fromFormData(
338
+ formData: Record<string, any>,
339
+ ): Record<string, unknown> {
340
+ const config: Record<string, unknown> = {};
341
+ const payload: Record<string, unknown> = {};
342
+
343
+ for (const [key, value] of Object.entries(formData ?? {})) {
344
+ if (key.startsWith(CONFIG_PREFIX)) {
345
+ config[key.slice(CONFIG_PREFIX.length)] = value;
346
+ } else if (key === 'propertymap') {
347
+ payload.propertymap = fromRows(value);
348
+ } else if (key === 'groupmap') {
349
+ payload.groupmap = fromGroupRows(value);
350
+ } else if (!key.startsWith('@')) {
351
+ payload[key] = value;
352
+ }
353
+ }
354
+
355
+ payload.config = config;
356
+ payload.enabled = Boolean(payload.enabled);
357
+ payload.show_in_login = Boolean(payload.show_in_login);
358
+ if (typeof payload.id === 'string') {
359
+ payload.id = payload.id.trim();
360
+ }
361
+ return payload;
362
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { returnUrl } from './returnUrl';
4
+
5
+ describe('returnUrl', () => {
6
+ it('uses an explicit return_url', () => {
7
+ expect(returnUrl('?return_url=/some/page', '/login')).toBe('/some/page');
8
+ });
9
+
10
+ it('accepts came_from too', () => {
11
+ expect(returnUrl('?came_from=/some/page', '/login')).toBe('/some/page');
12
+ });
13
+
14
+ it('falls back to the site root from /login', () => {
15
+ expect(returnUrl('', '/login')).toBe('/');
16
+ });
17
+
18
+ it('strips a trailing /login from a nested path', () => {
19
+ expect(returnUrl('', '/a/folder/login')).toBe('/a/folder');
20
+ });
21
+
22
+ it.each([
23
+ 'https://evil.example/phish',
24
+ '//evil.example/phish',
25
+ 'http://evil.example',
26
+ ])('refuses the off-site target %s', (target) => {
27
+ // On the frontend too: the backend drops these as well, but a target
28
+ // that never leaves the browser would never reach the backend to be
29
+ // checked.
30
+ expect(
31
+ returnUrl(`?return_url=${encodeURIComponent(target)}`, '/login'),
32
+ ).toBe('/');
33
+ });
34
+
35
+ it('refuses a relative target that is not rooted', () => {
36
+ expect(returnUrl('?return_url=some/page', '/login')).toBe('/');
37
+ });
38
+
39
+ it('ignores a repeated parameter rather than guessing', () => {
40
+ expect(returnUrl('?return_url=/a&return_url=/b', '/login')).toBe('/');
41
+ });
42
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Deciding where a signed-in user lands.
3
+ *
4
+ * Pure, and separate from the container, because this is an open-redirect
5
+ * check and those are worth testing on their own.
6
+ * @module helpers/returnUrl
7
+ */
8
+
9
+ /**
10
+ * Work out where to send the user once they are signed in.
11
+ *
12
+ * Only site-relative targets survive. An absolute or protocol-relative URL
13
+ * here would be an open redirect; the backend refuses those too, but a target
14
+ * that never leaves the browser would never reach the backend to be checked.
15
+ *
16
+ * @param search The location's query string.
17
+ * @param pathname The location's path.
18
+ * @returns A path inside this site.
19
+ */
20
+ export function returnUrl(search: string, pathname: string): string {
21
+ const params = new URLSearchParams(search);
22
+ const values = [
23
+ ...params.getAll('return_url'),
24
+ ...params.getAll('came_from'),
25
+ ];
26
+ const requested = values.length === 1 ? values[0] : undefined;
27
+
28
+ if (
29
+ requested &&
30
+ requested.startsWith('/') &&
31
+ // "//evil.example" is protocol-relative, not site-relative.
32
+ !requested.startsWith('//')
33
+ ) {
34
+ return requested;
35
+ }
36
+
37
+ if (pathname === '/login') {
38
+ return '/';
39
+ }
40
+ return pathname.replace(/\/login$/, '') || '/';
41
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Conversion between a stored `{key: value}` map and the rows an editor shows.
3
+ *
4
+ * The backend stores mappings as objects, which is the shape that reads well
5
+ * as a registry record. An editor cannot work in that shape: a row the
6
+ * operator has just added has no key yet, so every unfinished row would
7
+ * collapse onto the same one.
8
+ *
9
+ * Rows therefore carry an `@id`. That is Volto's `ObjectListWidget`
10
+ * contract -- it keys its drag list on `o['@id']` and renders nothing for a
11
+ * row without one -- and it is what lets two unfinished rows coexist.
12
+ *
13
+ * Two mappings use this: the property map, whose halves are a claim and a
14
+ * user field, and the group map, whose halves are a provider group and a
15
+ * local one. Only the field names differ, so the field names are the
16
+ * parameter and everything else is shared.
17
+ * @module helpers/rowmap
18
+ */
19
+
20
+ /**
21
+ * The least a row must be for the list widget to render it.
22
+ *
23
+ * The two halves are named by the caller, so they are not part of this type;
24
+ * each mapping declares a row type of its own and passes it as `R` below.
25
+ */
26
+ export interface MapRow {
27
+ '@id': string;
28
+ }
29
+
30
+ /**
31
+ * Mint an id for a row.
32
+ *
33
+ * @returns A value unique among the rows on the page.
34
+ */
35
+ export function rowId(): string {
36
+ const maybe = globalThis.crypto as { randomUUID?: () => string } | undefined;
37
+ if (maybe?.randomUUID) {
38
+ return maybe.randomUUID();
39
+ }
40
+ // Only reached on a runtime without webcrypto; uniqueness within one
41
+ // editing session is all this needs.
42
+ return `row-${Math.random().toString(36).slice(2)}`;
43
+ }
44
+
45
+ /** Converters between one stored map and its editor rows. */
46
+ export interface RowConverters<R extends MapRow> {
47
+ toRows: (map: Record<string, string> | undefined) => R[];
48
+ fromRows: (rows: R[] | undefined) => Record<string, string>;
49
+ }
50
+
51
+ /**
52
+ * Build the converters for a mapping whose halves are named this way.
53
+ *
54
+ * The row type is the caller's, and the two casts below are where the
55
+ * dynamic field names are reconciled with it. They are contained here on
56
+ * purpose: each mapping's own module then has a fully typed pair of
57
+ * functions and no cast of its own.
58
+ *
59
+ * @param keyField Name of the row field holding the map's key.
60
+ * @param valueField Name of the row field holding the map's value.
61
+ * @returns The pair of converters.
62
+ */
63
+ export function rowConverters<R extends MapRow>(
64
+ keyField: string,
65
+ valueField: string,
66
+ ): RowConverters<R> {
67
+ return {
68
+ /**
69
+ * Turn a stored map into editor rows.
70
+ *
71
+ * @param map The stored mapping.
72
+ * @returns One row per entry, in the map's own order.
73
+ */
74
+ toRows(map) {
75
+ return Object.entries(map ?? {}).map(
76
+ ([key, value]) =>
77
+ ({
78
+ '@id': rowId(),
79
+ [keyField]: key,
80
+ [valueField]: value,
81
+ }) as unknown as R,
82
+ );
83
+ },
84
+
85
+ /**
86
+ * Turn editor rows back into a stored map.
87
+ *
88
+ * Rows missing either half are dropped: they are a row the operator
89
+ * started and has not finished, not a mapping. This is why rows are held
90
+ * as state rather than derived from the map -- a blank row must survive
91
+ * on screen while being absent from what gets saved. A repeated key keeps
92
+ * the last row, which is what the object it becomes would do anyway.
93
+ *
94
+ * @param rows Rows from the editor.
95
+ * @returns The stored mapping.
96
+ */
97
+ fromRows(rows) {
98
+ const map: Record<string, string> = {};
99
+ for (const row of rows ?? []) {
100
+ const halves = row as unknown as Record<string, string | undefined>;
101
+ const key = (halves?.[keyField] ?? '').trim();
102
+ const value = (halves?.[valueField] ?? '').trim();
103
+ if (key && value) {
104
+ map[key] = value;
105
+ }
106
+ }
107
+ return map;
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,78 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ /**
4
+ * Stand in for Volto's `runtimeConfig`.
5
+ *
6
+ * A stable object whose keys the tests mutate, because the helper reads it at
7
+ * call time rather than binding a value at import.
8
+ */
9
+ const { runtime } = vi.hoisted(() => ({
10
+ runtime: {} as Record<string, string | undefined>,
11
+ }));
12
+
13
+ vi.mock('@plone/volto/runtime_config', () => ({ runtimeConfig: runtime }));
14
+
15
+ const { default: config } = await import('@plone/volto/registry');
16
+ const { showPloneLogin, SHOW_PLONE_LOGIN_ENV } = await import(
17
+ './showPloneLogin'
18
+ );
19
+
20
+ describe('showPloneLogin', () => {
21
+ beforeEach(() => {
22
+ delete runtime[SHOW_PLONE_LOGIN_ENV];
23
+ config.settings.identityShowPloneLogin = false;
24
+ });
25
+
26
+ it('is off when nothing says otherwise', () => {
27
+ expect(showPloneLogin()).toBe(false);
28
+ });
29
+
30
+ it('is on when the environment says so', () => {
31
+ // The whole point of the change: this value arrives at run time, so one
32
+ // image serves a site that wants the password form and one that does not.
33
+ runtime[SHOW_PLONE_LOGIN_ENV] = 'true';
34
+
35
+ expect(showPloneLogin()).toBe(true);
36
+ });
37
+
38
+ it('reads the word "false" as off', () => {
39
+ // Not `Boolean(value)`, which reads the string "false" as true and turns
40
+ // an operator switching the form *off* into a site that still shows it.
41
+ config.settings.identityShowPloneLogin = true;
42
+ runtime[SHOW_PLONE_LOGIN_ENV] = 'false';
43
+
44
+ expect(showPloneLogin()).toBe(false);
45
+ });
46
+
47
+ it('falls back to the setting when the environment is silent', () => {
48
+ // So a project shipping its own default keeps it.
49
+ config.settings.identityShowPloneLogin = true;
50
+
51
+ expect(showPloneLogin()).toBe(true);
52
+ });
53
+
54
+ it('treats an empty variable as silence rather than as off', () => {
55
+ // An unset variable and one set to nothing reach the container the same
56
+ // way, and neither is an operator asking for the form to go away.
57
+ config.settings.identityShowPloneLogin = true;
58
+ runtime[SHOW_PLONE_LOGIN_ENV] = '';
59
+
60
+ expect(showPloneLogin()).toBe(true);
61
+ });
62
+
63
+ it('lets the environment override the setting in both directions', () => {
64
+ config.settings.identityShowPloneLogin = true;
65
+ runtime[SHOW_PLONE_LOGIN_ENV] = 'off';
66
+ expect(showPloneLogin()).toBe(false);
67
+
68
+ config.settings.identityShowPloneLogin = false;
69
+ runtime[SHOW_PLONE_LOGIN_ENV] = 'on';
70
+ expect(showPloneLogin()).toBe(true);
71
+ });
72
+
73
+ it('survives a runtimeConfig that carries nothing', () => {
74
+ // Volto builds it by filtering `process.env` for `RAZZLE_*`, so a
75
+ // container with none is an empty object rather than an error.
76
+ expect(() => showPloneLogin()).not.toThrow();
77
+ });
78
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Whether Plone's own username/password form is offered on the login page.
3
+ * @module helpers/showPloneLogin
4
+ */
5
+ import { runtimeConfig } from '@plone/volto/runtime_config';
6
+ import config from '@plone/volto/registry';
7
+
8
+ import { asBoolean } from '../config/settings';
9
+
10
+ /**
11
+ * The environment variable a deployment answers this with.
12
+ *
13
+ * `RAZZLE_`-prefixed because that is the only prefix Volto carries into
14
+ * `runtimeConfig`.
15
+ */
16
+ export const SHOW_PLONE_LOGIN_ENV = 'RAZZLE_IDENTITY_SHOW_PLONE_LOGIN';
17
+
18
+ /**
19
+ * Decide whether to offer the password form.
20
+ *
21
+ * Read through Volto's `runtimeConfig` rather than `process.env`, which is
22
+ * what makes this a *runtime* answer instead of a property of the image.
23
+ * `process.env.RAZZLE_...`, written out literally, is substituted into the
24
+ * browser bundle by webpack's DefinePlugin while `pnpm build` runs -- so a
25
+ * value supplied to a running container reaches the node process and never
26
+ * the browser. It looks like it works and does nothing, and two sites wanting
27
+ * two answers need two images.
28
+ *
29
+ * `runtimeConfig` avoids that on both sides, which is why it is used instead
30
+ * of reading `window.env` directly:
31
+ *
32
+ * - on the server it enumerates `process.env` and filters to `RAZZLE_*`, with
33
+ * computed keys DefinePlugin cannot rewrite, so the node process is read as
34
+ * it actually is;
35
+ * - on the client it reads `window.env`, which the server serialized into the
36
+ * page while rendering it.
37
+ *
38
+ * Both therefore see the same value, and the server-rendered markup matches
39
+ * what React hydrates. Reading only `window.env` would render the default on
40
+ * the server and the real answer in the browser -- a mismatch, and a flash of
41
+ * the wrong form.
42
+ *
43
+ * The setting is the fallback rather than the source, so a project that ships
44
+ * its own default keeps it and a deployment can still override it.
45
+ *
46
+ * @returns Whether to offer the password form.
47
+ */
48
+ export function showPloneLogin(): boolean {
49
+ return asBoolean(
50
+ (runtimeConfig as Record<string, string | undefined>)?.[
51
+ SHOW_PLONE_LOGIN_ENV
52
+ ],
53
+ Boolean(config.settings.identityShowPloneLogin),
54
+ );
55
+ }