@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,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { fromRows, toRows } from './propertymap';
4
+
5
+ describe('toRows', () => {
6
+ it('turns a map into rows', () => {
7
+ expect(toRows({ login: 'username' })).toEqual([
8
+ { '@id': expect.any(String), claim: 'login', field: 'username' },
9
+ ]);
10
+ });
11
+
12
+ it('gives every row an @id', () => {
13
+ // ObjectListWidget keys its drag list on it and renders nothing for a
14
+ // row without one -- a silent empty list, not an error.
15
+ const rows = toRows({ login: 'username', name: 'fullname' });
16
+
17
+ expect(rows.every((row) => row['@id'])).toBe(true);
18
+ expect(rows[0]['@id']).not.toEqual(rows[1]['@id']);
19
+ });
20
+
21
+ it('survives an absent map', () => {
22
+ expect(toRows(undefined)).toEqual([]);
23
+ });
24
+ });
25
+
26
+ describe('fromRows', () => {
27
+ it('turns rows back into a map', () => {
28
+ expect(
29
+ fromRows([{ '@id': 'a', claim: 'login', field: 'username' }]),
30
+ ).toEqual({ login: 'username' });
31
+ });
32
+
33
+ it('drops a row the operator has not finished', () => {
34
+ // A blank row is what pressing Add produces, and it must not be stored.
35
+ const blank = { '@id': 'a', claim: '', field: '' };
36
+ expect(fromRows([blank])).toEqual({});
37
+ expect(fromRows([{ ...blank, claim: 'login' }])).toEqual({});
38
+ expect(fromRows([{ ...blank, field: 'username' }])).toEqual({});
39
+ });
40
+
41
+ it('trims what the operator typed', () => {
42
+ expect(
43
+ fromRows([{ '@id': 'a', claim: ' login ', field: ' username ' }]),
44
+ ).toEqual({ login: 'username' });
45
+ });
46
+
47
+ it('keeps the last of a repeated claim', () => {
48
+ expect(
49
+ fromRows([
50
+ { '@id': 'a', claim: 'login', field: 'username' },
51
+ { '@id': 'b', claim: 'login', field: 'fullname' },
52
+ ]),
53
+ ).toEqual({ login: 'fullname' });
54
+ });
55
+
56
+ it('survives absent rows', () => {
57
+ expect(fromRows(undefined)).toEqual({});
58
+ });
59
+
60
+ it('round-trips', () => {
61
+ const map = { login: 'username', 'address.formatted': 'location' };
62
+
63
+ expect(fromRows(toRows(map))).toEqual(map);
64
+ });
65
+
66
+ it('ignores the row id', () => {
67
+ expect(fromRows([{ '@id': 'anything', claim: 'a', field: 'b' }])).toEqual({
68
+ a: 'b',
69
+ });
70
+ });
71
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Conversion between the stored property map and the rows an editor shows.
3
+ *
4
+ * The mechanics are shared with the group map and live in
5
+ * {@link module:helpers/rowmap}; what belongs here is only the names this
6
+ * mapping's two halves go by.
7
+ * @module helpers/propertymap
8
+ */
9
+
10
+ import { rowConverters } from './rowmap';
11
+
12
+ /** One row of the mapping editor. */
13
+ export interface PropertyMapRow {
14
+ '@id': string;
15
+ claim: string;
16
+ field: string;
17
+ }
18
+
19
+ const converters = rowConverters<PropertyMapRow>('claim', 'field');
20
+
21
+ /**
22
+ * Turn a stored map into editor rows.
23
+ *
24
+ * @param map Claim path to user field.
25
+ * @returns One row per entry, in the map's own order.
26
+ */
27
+ export function toRows(
28
+ map: Record<string, string> | undefined,
29
+ ): PropertyMapRow[] {
30
+ return converters.toRows(map);
31
+ }
32
+
33
+ /**
34
+ * Turn editor rows back into a stored map.
35
+ *
36
+ * @param rows Rows from the editor.
37
+ * @returns Claim path to user field.
38
+ */
39
+ export function fromRows(
40
+ rows: PropertyMapRow[] | undefined,
41
+ ): Record<string, string> {
42
+ return converters.fromRows(rows);
43
+ }
@@ -0,0 +1,410 @@
1
+ /**
2
+ * The provider form is composed, not invented.
3
+ *
4
+ * This file used to be 618 lines, and most of them asserted things about a
5
+ * schema language this package had made up: that a `secret` descriptor became
6
+ * a password widget, that `order` sorted the fields, that a `choice` became a
7
+ * select. None of that is decided here any more — the backend serializes two
8
+ * interfaces with `plone.restapi` and this helper merges them — so what is
9
+ * left to test is the merge, and the one thing the merge must never do, which
10
+ * is have an opinion about a field.
11
+ */
12
+ import { describe, expect, it } from 'vitest';
13
+
14
+ import {
15
+ CONFIG_PREFIX,
16
+ MAPPING_FIELDSET,
17
+ SETTINGS_FIELDSET,
18
+ fromFormData,
19
+ providerSchema,
20
+ suggestedProviderId,
21
+ toFormData,
22
+ } from './providerSchema';
23
+ import type { Driver } from '../types';
24
+
25
+ const intl = {
26
+ formatMessage: (m: { defaultMessage: string }) => m.defaultMessage,
27
+ } as any;
28
+
29
+ /**
30
+ * What `@identity-providers` sends: `IProviderRecords`, serialized.
31
+ *
32
+ * Including `driver`, `propertymap` and `groupmap`, which it really does
33
+ * send -- they are fields of a provider's storage. Leaving them out of this
34
+ * fixture is what let the merge below go unnoticed once already.
35
+ */
36
+ const SERVED = {
37
+ type: 'object',
38
+ properties: {
39
+ driver: { title: 'Driver', type: 'string' },
40
+ title: { title: 'Title', type: 'string' },
41
+ enabled: { title: 'Enabled', type: 'boolean' },
42
+ propertymap: { title: 'Property map', type: 'object' },
43
+ groupmap: { title: 'Group map', type: 'object' },
44
+ icon: { title: 'Icon', type: 'string', widget: 'provider_icon' },
45
+ background_color: { title: 'Background colour', widget: 'color_picker' },
46
+ },
47
+ required: ['title'],
48
+ fieldsets: [
49
+ {
50
+ id: 'default',
51
+ title: 'Default',
52
+ fields: ['driver', 'title', 'enabled', 'propertymap', 'groupmap'],
53
+ },
54
+ { id: 'style', title: 'Style', fields: ['icon', 'background_color'] },
55
+ ],
56
+ };
57
+
58
+ /** What one entry of `@identity-drivers` sends: the driver's settings. */
59
+ const GITHUB: Driver = {
60
+ id: 'github',
61
+ title: 'GitHub',
62
+ schema: {
63
+ properties: {
64
+ client_id: { title: 'Client ID', type: 'string' },
65
+ client_secret: { title: 'Client secret', widget: 'password' },
66
+ },
67
+ required: ['client_id', 'client_secret'],
68
+ fieldsets: [
69
+ {
70
+ id: 'default',
71
+ title: 'Default',
72
+ fields: ['client_id', 'client_secret'],
73
+ },
74
+ ],
75
+ },
76
+ } as Driver;
77
+
78
+ /** A driver whose providers have groups: the only difference that matters. */
79
+ const OIDC: Driver = {
80
+ id: 'oidc',
81
+ title: 'OpenID Connect',
82
+ schema: {
83
+ properties: {
84
+ issuer: { title: 'Issuer URL', type: 'string' },
85
+ group_claim: { title: 'Groups arrive in the claim', type: 'string' },
86
+ },
87
+ required: ['issuer'],
88
+ fieldsets: [
89
+ { id: 'default', title: 'Default', fields: ['issuer', 'group_claim'] },
90
+ ],
91
+ },
92
+ } as Driver;
93
+
94
+ const DRIVERS = [GITHUB, OIDC];
95
+
96
+ describe('providerSchema', () => {
97
+ it('carries the served properties through untouched', () => {
98
+ // The point of the whole change: whatever the backend decided about a
99
+ // field — its widget, its title, its type — arrives unaltered.
100
+ const schema = providerSchema(SERVED, DRIVERS, 'github', false, intl);
101
+
102
+ expect(schema.properties.icon).toEqual(SERVED.properties.icon);
103
+ expect(schema.properties.background_color).toEqual(
104
+ SERVED.properties.background_color,
105
+ );
106
+ });
107
+
108
+ it('keeps the backend fieldsets and their order', () => {
109
+ const schema = providerSchema(SERVED, DRIVERS, 'github', false, intl);
110
+
111
+ expect(schema.fieldsets.map((f) => f.id)).toEqual([
112
+ 'default',
113
+ 'style',
114
+ SETTINGS_FIELDSET,
115
+ MAPPING_FIELDSET,
116
+ ]);
117
+ });
118
+
119
+ it('prefixes the driver settings so the two halves cannot collide', () => {
120
+ // A driver may perfectly well declare a `title`, and it is not the
121
+ // provider's.
122
+ const schema = providerSchema(SERVED, DRIVERS, 'github', false, intl);
123
+
124
+ expect(schema.properties[`${CONFIG_PREFIX}client_id`]).toEqual({
125
+ title: 'Client ID',
126
+ type: 'string',
127
+ });
128
+ expect(schema.properties.title).toEqual(SERVED.properties.title);
129
+ });
130
+
131
+ it('carries required through from both halves', () => {
132
+ const schema = providerSchema(SERVED, DRIVERS, 'github', false, intl);
133
+
134
+ expect(schema.required).toContain('title');
135
+ expect(schema.required).toContain(`${CONFIG_PREFIX}client_secret`);
136
+ });
137
+
138
+ it('renders the driver settings in the order the driver declared', () => {
139
+ const schema = providerSchema(SERVED, DRIVERS, 'github', false, intl);
140
+ const settings = schema.fieldsets.find((f) => f.id === SETTINGS_FIELDSET);
141
+
142
+ expect(settings?.fields).toEqual([
143
+ `${CONFIG_PREFIX}client_id`,
144
+ `${CONFIG_PREFIX}client_secret`,
145
+ ]);
146
+ });
147
+
148
+ it('asks for the driver and the id only while adding', () => {
149
+ const adding = providerSchema(SERVED, DRIVERS, 'github', true, intl);
150
+ const editing = providerSchema(SERVED, DRIVERS, 'github', false, intl);
151
+
152
+ expect(adding.fieldsets[0].fields.slice(0, 2)).toEqual(['driver', 'id']);
153
+ expect(editing.properties.driver).toBeUndefined();
154
+ expect(editing.properties.id).toBeUndefined();
155
+ });
156
+
157
+ it('survives a backend that sent no schema', () => {
158
+ // A client talking to an older backend, or a request that failed. An
159
+ // empty form is recoverable; a crash on `schema.fieldsets` is not.
160
+ const schema = providerSchema(undefined, DRIVERS, 'github', false, intl);
161
+
162
+ expect(schema.fieldsets.map((f) => f.id)).toEqual([
163
+ SETTINGS_FIELDSET,
164
+ MAPPING_FIELDSET,
165
+ ]);
166
+ });
167
+
168
+ it('renders the group map only for a driver that has groups', () => {
169
+ // The backend says a driver's providers have groups by putting a
170
+ // `group_claim` field in its settings schema, and this is the same
171
+ // switch. GitHub declares none, so mapping them is a question with no
172
+ // answer -- and a map stored against such a provider grants nothing.
173
+ const withGroups = providerSchema(SERVED, DRIVERS, 'oidc', false, intl);
174
+ const without = providerSchema(SERVED, DRIVERS, 'github', false, intl);
175
+
176
+ expect(withGroups.properties.groupmap).toBeDefined();
177
+ expect(
178
+ withGroups.fieldsets.find((f) => f.id === MAPPING_FIELDSET)?.fields,
179
+ ).toEqual(['propertymap', 'groupmap']);
180
+
181
+ expect(without.properties.groupmap).toBeUndefined();
182
+ expect(
183
+ without.fieldsets.find((f) => f.id === MAPPING_FIELDSET)?.fields,
184
+ ).toEqual(['propertymap']);
185
+ });
186
+
187
+ it('drops the served driver field rather than merging it', () => {
188
+ // `IProviderRecords` has a `driver` TextLine, because storage does. The
189
+ // form asks the question with a picker over the registered drivers, and
190
+ // only while adding -- so the served description of it must not arrive
191
+ // last and overwrite that.
192
+ const adding = providerSchema(SERVED, DRIVERS, 'github', true, intl);
193
+
194
+ expect(adding.properties.driver?.choices).toEqual([
195
+ ['github', 'GitHub'],
196
+ ['oidc', 'OpenID Connect'],
197
+ ]);
198
+ expect(adding.properties.driver?.type).toBeUndefined();
199
+ });
200
+
201
+ it('never lists a mapping twice', () => {
202
+ // The served default fieldset carries `propertymap` and `groupmap`, and
203
+ // this file rebuilds both as row editors in a fieldset of its own. A
204
+ // field named in two fieldsets is rendered in both, once as a `Dict`
205
+ // that Volto has no widget for.
206
+ const schema = providerSchema(SERVED, DRIVERS, 'oidc', false, intl);
207
+ const listed = schema.fieldsets.flatMap((f) => f.fields);
208
+
209
+ expect(listed.filter((name) => name === 'propertymap')).toHaveLength(1);
210
+ expect(listed.filter((name) => name === 'groupmap')).toHaveLength(1);
211
+ expect(schema.fieldsets.find((f) => f.id === 'default')?.fields).toEqual([
212
+ 'title',
213
+ 'enabled',
214
+ ]);
215
+ });
216
+
217
+ it('survives a driver that is not registered', () => {
218
+ const schema = providerSchema(SERVED, DRIVERS, 'gone', false, intl);
219
+
220
+ expect(schema.fieldsets.map((f) => f.id)).toEqual([
221
+ 'default',
222
+ 'style',
223
+ MAPPING_FIELDSET,
224
+ ]);
225
+ });
226
+ });
227
+
228
+ describe('suggestedProviderId', () => {
229
+ it('slugifies the driver title', () => {
230
+ expect(suggestedProviderId(DRIVERS, 'github')).toBe('github');
231
+ });
232
+
233
+ it('turns punctuation into separators rather than dropping it', () => {
234
+ const drivers = [{ id: 'x', title: 'Sign in (beta)' } as Driver];
235
+
236
+ expect(suggestedProviderId(drivers, 'x')).toBe('sign-in-beta');
237
+ });
238
+
239
+ it('is empty for a driver that is not there', () => {
240
+ expect(suggestedProviderId(DRIVERS, 'nope')).toBe('');
241
+ });
242
+ });
243
+
244
+ describe('toFormData', () => {
245
+ it('flattens the driver settings under the prefix', () => {
246
+ const data = toFormData({
247
+ title: 'GitHub',
248
+ config: { client_id: 'abc' },
249
+ });
250
+
251
+ expect(data[`${CONFIG_PREFIX}client_id`]).toBe('abc');
252
+ });
253
+
254
+ it('reads an absent show_in_login as shown', () => {
255
+ // A provider stored before the setting existed was on the login page, and
256
+ // reading the key back as false would take a site's buttons away.
257
+ expect(toFormData({ title: 'x' }).show_in_login).toBe(true);
258
+ });
259
+
260
+ it('hands the icon envelope to the widget unchanged', () => {
261
+ const envelope = 'filenameb64:aWNvbi5zdmc=;datab64:PHN2Zy8+';
262
+
263
+ expect(toFormData({ icon: envelope }).icon).toBe(envelope);
264
+ });
265
+ });
266
+
267
+ describe('fromFormData', () => {
268
+ it('nests the prefixed settings back under config', () => {
269
+ const payload = fromFormData({
270
+ title: 'GitHub',
271
+ [`${CONFIG_PREFIX}client_id`]: 'abc',
272
+ });
273
+
274
+ expect(payload.config).toEqual({ client_id: 'abc' });
275
+ expect(payload.title).toBe('GitHub');
276
+ });
277
+
278
+ it('drops the keys Volto adds to its own form data', () => {
279
+ const payload = fromFormData({ '@id': '/x', title: 'GitHub' });
280
+
281
+ expect(payload['@id']).toBeUndefined();
282
+ });
283
+
284
+ it('does not validate anything', () => {
285
+ // Every rule about what a value may be lives on the backend schema now.
286
+ // Trimming or correcting here would only hide which value a refusal is
287
+ // about.
288
+ const payload = fromFormData({ background_color: ' not a colour ' });
289
+
290
+ expect(payload.background_color).toBe(' not a colour ');
291
+ });
292
+ });
293
+
294
+ describe('providerSchema and the driver fieldsets', () => {
295
+ // A driver that groups its settings the way the shipped OIDC one does.
296
+ const GROUPED = [
297
+ {
298
+ id: 'oidc-generic',
299
+ title: 'OpenID Connect',
300
+ schema: {
301
+ properties: {
302
+ issuer: { title: 'Issuer', type: 'string' },
303
+ client_id: { title: 'Client ID', type: 'string' },
304
+ userid_source: { title: 'User id from', type: 'string' },
305
+ group_claim: { title: 'Group claim', type: 'string' },
306
+ sync_groups: { title: 'Sync groups', type: 'boolean' },
307
+ },
308
+ required: [],
309
+ fieldsets: [
310
+ {
311
+ id: 'default',
312
+ title: 'Default',
313
+ fields: ['issuer', 'client_id'],
314
+ },
315
+ { id: 'accounts', title: 'Accounts', fields: ['userid_source'] },
316
+ {
317
+ id: 'groups',
318
+ title: 'Groups',
319
+ fields: ['group_claim', 'sync_groups'],
320
+ },
321
+ ],
322
+ },
323
+ },
324
+ ];
325
+
326
+ it('gives each driver fieldset a tab of its own', () => {
327
+ const schema = providerSchema(SERVED, GROUPED, 'oidc-generic', false, intl);
328
+
329
+ expect(schema.fieldsets.map((f) => f.id)).toEqual([
330
+ 'default',
331
+ 'style',
332
+ SETTINGS_FIELDSET,
333
+ `${SETTINGS_FIELDSET}-accounts`,
334
+ `${SETTINGS_FIELDSET}-groups`,
335
+ MAPPING_FIELDSET,
336
+ ]);
337
+ });
338
+
339
+ it("namespaces them so a driver's fieldset cannot collide with the provider's", () => {
340
+ // The backend serves a `default` fieldset on both halves of this form,
341
+ // and two tabs with one id is a form that renders one of them.
342
+ const schema = providerSchema(SERVED, GROUPED, 'oidc-generic', false, intl);
343
+ const ids = schema.fieldsets.map((f) => f.id);
344
+
345
+ expect(new Set(ids).size).toBe(ids.length);
346
+ });
347
+
348
+ it("keeps the driver's own titles, which the backend translated", () => {
349
+ const schema = providerSchema(SERVED, GROUPED, 'oidc-generic', false, intl);
350
+ const groups = schema.fieldsets.find(
351
+ (f) => f.id === `${SETTINGS_FIELDSET}-groups`,
352
+ );
353
+
354
+ expect(groups?.title).toBe('Groups');
355
+ });
356
+
357
+ it("labels the driver's unnamed fieldset as Settings", () => {
358
+ // It is the one the driver did not name, so there is no title to keep.
359
+ const schema = providerSchema(SERVED, GROUPED, 'oidc-generic', false, intl);
360
+ const settings = schema.fieldsets.find((f) => f.id === SETTINGS_FIELDSET);
361
+
362
+ expect(settings?.title).toBe('Settings');
363
+ expect(settings?.fields).toEqual([
364
+ `${CONFIG_PREFIX}issuer`,
365
+ `${CONFIG_PREFIX}client_id`,
366
+ ]);
367
+ });
368
+
369
+ it('prefixes every field in every fieldset', () => {
370
+ const schema = providerSchema(SERVED, GROUPED, 'oidc-generic', false, intl);
371
+ const driverTabs = schema.fieldsets.filter((f) =>
372
+ f.id.startsWith(SETTINGS_FIELDSET),
373
+ );
374
+
375
+ for (const tab of driverTabs) {
376
+ for (const field of tab.fields) {
377
+ expect(field.startsWith(CONFIG_PREFIX)).toBe(true);
378
+ }
379
+ }
380
+ });
381
+
382
+ it('places every driver setting in exactly one tab', () => {
383
+ // A field in no tab is invisible in the form; one in two tabs is edited
384
+ // in two places.
385
+ const schema = providerSchema(SERVED, GROUPED, 'oidc-generic', false, intl);
386
+ const placed = schema.fieldsets
387
+ .filter((f) => f.id.startsWith(SETTINGS_FIELDSET))
388
+ .flatMap((f) => f.fields);
389
+
390
+ expect(new Set(placed).size).toBe(placed.length);
391
+ expect(new Set(placed)).toEqual(
392
+ new Set(
393
+ Object.keys(GROUPED[0].schema.properties).map(
394
+ (name) => `${CONFIG_PREFIX}${name}`,
395
+ ),
396
+ ),
397
+ );
398
+ });
399
+
400
+ it('still gives one tab to a driver that declares no fieldsets', () => {
401
+ // Which is what every driver looked like before the backend grouped
402
+ // them, and what a third-party driver that never declares one looks like
403
+ // now.
404
+ const schema = providerSchema(SERVED, DRIVERS, 'github', false, intl);
405
+
406
+ expect(
407
+ schema.fieldsets.filter((f) => f.id.startsWith(SETTINGS_FIELDSET)),
408
+ ).toHaveLength(1);
409
+ });
410
+ });