@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,182 @@
1
+ /**
2
+ * The client form is composed, not invented.
3
+ *
4
+ * What used to be asserted here — which grants exist, what each field is
5
+ * called, which of them are text boxes — is the backend's answer now,
6
+ * serialized by `plone.restapi` from `IClientRecords`. So the grants offered
7
+ * are exactly what the token endpoint implements, without this file holding a
8
+ * list that could drift from it.
9
+ *
10
+ * What is left to test is the composition, and the two questions that exist
11
+ * only while a client is being registered.
12
+ */
13
+ import { describe, expect, it } from 'vitest';
14
+
15
+ import {
16
+ EDITABLE,
17
+ clientSchema,
18
+ fromFormData,
19
+ toFormData,
20
+ } from './clientSchema';
21
+ import type { OAuthClient } from '../types';
22
+
23
+ const intl = {
24
+ formatMessage: (m: { defaultMessage: string }) => m.defaultMessage,
25
+ } as any;
26
+
27
+ /** What `@identity-clients` sends: `IClientRecords`, serialized. */
28
+ const SERVED = {
29
+ type: 'object',
30
+ properties: {
31
+ title: { title: 'Title', type: 'string' },
32
+ enabled: { title: 'Enabled', type: 'boolean' },
33
+ redirect_uris: { title: 'Redirect URIs', type: 'array', widget: 'token' },
34
+ grant_types: {
35
+ title: 'Grants',
36
+ type: 'array',
37
+ choices: [
38
+ ['authorization_code', 'authorization_code'],
39
+ ['client_credentials', 'client_credentials'],
40
+ ['refresh_token', 'refresh_token'],
41
+ ],
42
+ },
43
+ scope: { title: 'Scopes', type: 'array' },
44
+ service_user: { title: 'Acts as', type: 'string' },
45
+ },
46
+ required: ['title'],
47
+ fieldsets: [
48
+ { id: 'default', title: 'Default', fields: ['title', 'enabled'] },
49
+ {
50
+ id: 'flow',
51
+ title: 'Flow',
52
+ fields: ['redirect_uris', 'grant_types', 'scope', 'service_user'],
53
+ },
54
+ ],
55
+ };
56
+
57
+ describe('clientSchema', () => {
58
+ it('carries the served properties through untouched', () => {
59
+ const schema = clientSchema(SERVED, false, intl);
60
+
61
+ expect(schema.properties.grant_types).toEqual(
62
+ SERVED.properties.grant_types,
63
+ );
64
+ });
65
+
66
+ it('offers exactly the grants the backend implements', () => {
67
+ // Not a list this file keeps in step by hand. The vocabulary is built on
68
+ // the backend from what the token endpoint serves and what discovery
69
+ // advertises, so the three can no longer disagree.
70
+ const schema = clientSchema(SERVED, true, intl);
71
+
72
+ expect(
73
+ (schema.properties.grant_types as any).choices.map(
74
+ ([value]: [string, string]) => value,
75
+ ),
76
+ ).toEqual(['authorization_code', 'client_credentials', 'refresh_token']);
77
+ });
78
+
79
+ it('asks for the permanent id only when registering', () => {
80
+ expect(clientSchema(SERVED, true, intl).properties.client_id).toBeTruthy();
81
+ expect(
82
+ clientSchema(SERVED, false, intl).properties.client_id,
83
+ ).toBeUndefined();
84
+ });
85
+
86
+ it('requires the id it cannot invent', () => {
87
+ expect(clientSchema(SERVED, true, intl).required).toContain('client_id');
88
+ });
89
+
90
+ it('offers whether a client is public only at registration', () => {
91
+ // It decides whether a secret is minted, which is a fact about
92
+ // registration rather than a setting.
93
+ expect(clientSchema(SERVED, true, intl).properties.public).toBeTruthy();
94
+ expect(clientSchema(SERVED, false, intl).properties.public).toBeUndefined();
95
+ });
96
+
97
+ it('asks nothing an edit cannot change', () => {
98
+ const schema = clientSchema(SERVED, false, intl);
99
+ const offered = schema.fieldsets.flatMap((f) => f.fields);
100
+
101
+ expect(offered.every((field) => EDITABLE.includes(field))).toBe(true);
102
+ });
103
+
104
+ it('survives a backend that sent no schema', () => {
105
+ // An empty form is recoverable; a crash on `schema.fieldsets` is not.
106
+ const schema = clientSchema(undefined, true, intl);
107
+
108
+ expect(schema.fieldsets).toEqual([]);
109
+ });
110
+ });
111
+
112
+ describe('toFormData', () => {
113
+ it('starts a registration on the grant that makes it work', () => {
114
+ expect(toFormData().grant_types).toEqual(['authorization_code']);
115
+ });
116
+
117
+ it('edits a scope as the list of permissions it is', () => {
118
+ // The backend field is a tuple of choices, so nothing here splits a
119
+ // string any more -- the two conversions this helper used to do existed
120
+ // only because the record was one line of space-separated text.
121
+ const client = { scope: ['openid', 'profile', 'email'] } as OAuthClient;
122
+
123
+ expect(toFormData(client).scope).toEqual(['openid', 'profile', 'email']);
124
+ });
125
+
126
+ it('copies the scopes rather than sharing them', () => {
127
+ // The form mutates what it is given, and the client in the store is not
128
+ // the form's to edit.
129
+ const client = { scope: ['openid'] } as OAuthClient;
130
+
131
+ expect(toFormData(client).scope).not.toBe(client.scope);
132
+ });
133
+
134
+ it('has no scopes for a client that carries none', () => {
135
+ expect(toFormData({} as OAuthClient).scope).toEqual([]);
136
+ });
137
+ });
138
+
139
+ describe('fromFormData', () => {
140
+ it('sends the scopes as the list the record now holds', () => {
141
+ const payload = fromFormData({ scope: ['openid', 'email'] }, true);
142
+
143
+ expect(payload.scope).toEqual(['openid', 'email']);
144
+ });
145
+
146
+ it('drops the scope widget’s empty rows too', () => {
147
+ const payload = fromFormData({ scope: ['openid', '', null] }, true);
148
+
149
+ expect(payload.scope).toEqual(['openid']);
150
+ });
151
+
152
+ it('drops a list widget’s empty rows', () => {
153
+ const payload = fromFormData(
154
+ { redirect_uris: ['https://a.example/cb', '', null] },
155
+ true,
156
+ );
157
+
158
+ expect(payload.redirect_uris).toEqual(['https://a.example/cb']);
159
+ });
160
+
161
+ it('does not judge a redirect URI', () => {
162
+ // Whether this server will send a browser there is decided on the backend
163
+ // field, which refuses it and says why. Correcting it here would only
164
+ // hide which value the refusal is about.
165
+ const payload = fromFormData(
166
+ { redirect_uris: [' https://a.example/cb#frag '] },
167
+ true,
168
+ );
169
+
170
+ expect(payload.redirect_uris).toEqual([' https://a.example/cb#frag ']);
171
+ });
172
+
173
+ it('sends only what a PATCH accepts', () => {
174
+ // The backend refuses the whole request when it sees a field it will not
175
+ // change, so the two lists agreeing is what keeps an edit from failing
176
+ // wholesale.
177
+ const payload = fromFormData({ title: 'x', client_id: 'nope' }, false);
178
+
179
+ expect(payload.client_id).toBeUndefined();
180
+ expect(Object.keys(payload).every((k) => EDITABLE.includes(k))).toBe(true);
181
+ });
182
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The OAuth client form, composed from what the backend already sent.
3
+ *
4
+ * This file used to build the schema: its own grant list, its own labels, its
5
+ * own English. The backend serves one now, produced by `plone.restapi` from
6
+ * `IClientRecords` — the interface the client's own storage is described by —
7
+ * so the grants offered are exactly what the token endpoint implements, and
8
+ * every label is translated in the site's language.
9
+ *
10
+ * What is left is composition and the two questions that exist only while a
11
+ * client is being registered: the permanent `client_id`, and whether the
12
+ * client is public. Neither is a stored field an operator edits — the first
13
+ * becomes the record prefix and the second decides whether a secret is minted
14
+ * — so neither is in the schema, and both disappear from the edit form.
15
+ * @module helpers/clientSchema
16
+ */
17
+ import { defineMessages } from 'react-intl';
18
+
19
+ import type { JsonSchema, OAuthClient, VoltoSchema } from '../types';
20
+
21
+ import type { IntlShape } from 'react-intl';
22
+
23
+ /**
24
+ * Fields a `PATCH` may change.
25
+ *
26
+ * Mirrors `EDITABLE` in the backend's patch service, which refuses anything
27
+ * else outright rather than dropping it silently. `client_id` and the auth
28
+ * method are absent from both: changing either means registering a new
29
+ * client, and the form says so rather than offering a field the backend
30
+ * would reject.
31
+ */
32
+ export const EDITABLE = [
33
+ 'title',
34
+ 'redirect_uris',
35
+ 'grant_types',
36
+ 'scope',
37
+ 'enabled',
38
+ 'service_user',
39
+ ];
40
+
41
+ const messages = defineMessages({
42
+ registration: { id: 'Registration', defaultMessage: 'Registration' },
43
+ clientId: { id: 'Client ID', defaultMessage: 'Client ID' },
44
+ clientIdHelp: {
45
+ id: 'client-id-help',
46
+ defaultMessage:
47
+ 'Permanent. It is what every token already issued is bound to, so it ' +
48
+ 'cannot be changed afterwards.',
49
+ },
50
+ publicClient: { id: 'Public client', defaultMessage: 'Public client' },
51
+ publicClientHelp: {
52
+ id: 'client-public-help',
53
+ defaultMessage:
54
+ 'A native or browser app, which cannot keep a secret. No secret is ' +
55
+ 'minted and PKCE becomes mandatory instead.',
56
+ },
57
+ });
58
+
59
+ /**
60
+ * Build the client form from the schema the backend sent.
61
+ *
62
+ * @param served The schema from `@identity-clients`.
63
+ * @param adding Whether this registers a client, which asks two more
64
+ * questions than an edit does.
65
+ * @param intl For the labels of the two registration-only fields.
66
+ * @returns A Volto schema.
67
+ */
68
+ export function clientSchema(
69
+ served: JsonSchema | undefined,
70
+ adding: boolean,
71
+ intl: IntlShape,
72
+ ): VoltoSchema {
73
+ const properties: Record<string, Record<string, unknown>> = {};
74
+ const fieldsets: { id: string; title: string; fields: string[] }[] = [];
75
+ const required = [...(served?.required ?? [])];
76
+
77
+ if (adding) {
78
+ properties.client_id = {
79
+ title: intl.formatMessage(messages.clientId),
80
+ description: intl.formatMessage(messages.clientIdHelp),
81
+ type: 'string',
82
+ };
83
+ properties.public = {
84
+ title: intl.formatMessage(messages.publicClient),
85
+ description: intl.formatMessage(messages.publicClientHelp),
86
+ type: 'boolean',
87
+ };
88
+ required.push('client_id');
89
+ }
90
+
91
+ Object.assign(properties, served?.properties ?? {});
92
+
93
+ (served?.fieldsets ?? []).forEach((fieldset, index) => {
94
+ const fields =
95
+ index === 0
96
+ ? [
97
+ ...(adding ? ['client_id'] : []),
98
+ ...fieldset.fields.filter(
99
+ (name) => adding || EDITABLE.includes(name),
100
+ ),
101
+ ...(adding ? ['public'] : []),
102
+ ]
103
+ : fieldset.fields.filter((name) => adding || EDITABLE.includes(name));
104
+ if (fields.length) {
105
+ fieldsets.push({
106
+ id: fieldset.id,
107
+ title:
108
+ index === 0 && adding
109
+ ? intl.formatMessage(messages.registration)
110
+ : fieldset.title,
111
+ fields,
112
+ });
113
+ }
114
+ });
115
+
116
+ return { fieldsets, properties, required };
117
+ }
118
+
119
+ /**
120
+ * Turn a client from the API into form data.
121
+ *
122
+ * @param client The client, or undefined when registering.
123
+ * @returns Form data.
124
+ */
125
+ export function toFormData(client?: OAuthClient): Record<string, unknown> {
126
+ if (!client) {
127
+ // `Form` seeds the rest from the schema's own defaults; what it cannot
128
+ // know is that a new registration is meant to work, which is what the
129
+ // authorization-code grant amounts to.
130
+ return { grant_types: ['authorization_code'], public: false };
131
+ }
132
+ return {
133
+ title: client.title ?? '',
134
+ enabled: client.enabled ?? true,
135
+ grant_types: [...(client.grant_types ?? [])],
136
+ redirect_uris: [...(client.redirect_uris ?? [])],
137
+ scope: [...(client.scope ?? [])],
138
+ service_user: client.service_user ?? '',
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Turn submitted form data back into the API payload.
144
+ *
145
+ * Shape only. Whether a redirect URI is one this server will send a browser
146
+ * to is decided on the backend field, which refuses a fragment, a wildcard, a
147
+ * `javascript:` scheme and plain HTTP off the loopback -- and says which. The
148
+ * blank entries dropped below are a list widget's empty rows, not a judgement
149
+ * about any value somebody typed.
150
+ *
151
+ * @param formData What the form submitted.
152
+ * @param adding Whether this registers a client; an edit sends only what
153
+ * the backend accepts, since it refuses the whole request otherwise.
154
+ * @returns The body for POST or PATCH.
155
+ */
156
+ export function fromFormData(
157
+ formData: Record<string, any>,
158
+ adding: boolean,
159
+ ): Record<string, unknown> {
160
+ const data = formData ?? {};
161
+ const payload: Record<string, unknown> = {
162
+ title: data.title ?? '',
163
+ grant_types: [...(data.grant_types ?? [])],
164
+ redirect_uris: (data.redirect_uris ?? []).filter(Boolean),
165
+ scope: (data.scope ?? []).filter(Boolean),
166
+ service_user: data.service_user ?? '',
167
+ };
168
+
169
+ if (adding) {
170
+ payload.client_id = String(data.client_id ?? '').trim();
171
+ payload.public = Boolean(data.public);
172
+ return payload;
173
+ }
174
+ payload.enabled = Boolean(data.enabled);
175
+ // Filtered rather than assembled a second time: the backend refuses the
176
+ // whole request when it sees a field it will not change, so the two lists
177
+ // agreeing is what keeps an edit from failing wholesale.
178
+ return Object.fromEntries(
179
+ Object.entries(payload).filter(([field]) => EDITABLE.includes(field)),
180
+ );
181
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { afterLogin, toAppPath } from './firstLogin';
4
+ import type { MyProfile } from '../types';
5
+
6
+ function profile(overrides: Partial<MyProfile> = {}): MyProfile {
7
+ return {
8
+ '@id': 'http://backend:8080/Plone/@my-profile',
9
+ userid: 'alice-userid',
10
+ profile: 'http://backend:8080/Plone/identity-profiles/alice-userid',
11
+ review_state: 'incomplete',
12
+ missing: [],
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe('toAppPath', () => {
18
+ it('strips a configured backend base', () => {
19
+ expect(
20
+ toAppPath(
21
+ 'http://backend:8080/Plone/identity-profiles/alice',
22
+ 'http://backend:8080/Plone',
23
+ ),
24
+ ).toBe('/identity-profiles/alice');
25
+ });
26
+
27
+ it('strips the origin when there is no configured base', () => {
28
+ expect(toAppPath('https://example.com/identity-profiles/alice')).toBe(
29
+ '/identity-profiles/alice',
30
+ );
31
+ });
32
+
33
+ it('leaves an already relative path alone', () => {
34
+ expect(toAppPath('/identity-profiles/alice')).toBe(
35
+ '/identity-profiles/alice',
36
+ );
37
+ });
38
+
39
+ it('adds the leading slash a relative answer may lack', () => {
40
+ expect(toAppPath('identity-profiles/alice')).toBe(
41
+ '/identity-profiles/alice',
42
+ );
43
+ });
44
+
45
+ it('handles an origin with nothing after it', () => {
46
+ expect(toAppPath('https://example.com')).toBe('/');
47
+ });
48
+
49
+ it('treats an empty URL as the site root', () => {
50
+ expect(toAppPath('')).toBe('/');
51
+ });
52
+ });
53
+
54
+ describe('afterLogin', () => {
55
+ it('diverts a user whose profile is incomplete to its edit form', () => {
56
+ // The edit form rather than the profile: it is missing something the
57
+ // site requires, and the view would ask for one more click to reach the
58
+ // only thing they can do about that.
59
+ expect(afterLogin(profile(), '/news', 'http://backend:8080/Plone')).toBe(
60
+ '/identity-profiles/alice-userid/edit',
61
+ );
62
+ });
63
+
64
+ it('leaves a completed profile alone', () => {
65
+ expect(afterLogin(profile({ review_state: 'complete' }), '/news')).toBe(
66
+ '/news',
67
+ );
68
+ });
69
+
70
+ it('does not divert a deactivated profile', () => {
71
+ // Diverting here would send somebody to a Profile they cannot even view.
72
+ expect(afterLogin(profile({ review_state: 'deactivated' }), '/news')).toBe(
73
+ '/news',
74
+ );
75
+ });
76
+
77
+ it('carries on when the user has no profile', () => {
78
+ expect(
79
+ afterLogin(profile({ profile: null, review_state: null }), '/news'),
80
+ ).toBe('/news');
81
+ });
82
+
83
+ it('carries on in a site without the extra installed', () => {
84
+ expect(afterLogin(null, '/news')).toBe('/news');
85
+ });
86
+
87
+ it('carries on before the answer has loaded', () => {
88
+ expect(afterLogin(undefined, '/news')).toBe('/news');
89
+ });
90
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Deciding where a just-signed-in user lands when the site has Profiles.
3
+ *
4
+ * Pure, and separate from the container, for the same reason `returnUrl` is:
5
+ * the decision is small, it is easy to get subtly wrong, and every branch of
6
+ * it deserves a test that does not need a store.
7
+ * @module helpers/firstLogin
8
+ */
9
+
10
+ import { rememberReturn } from './profileGate';
11
+
12
+ import type { MyProfile } from '../types';
13
+
14
+ /**
15
+ * Turn an absolute backend URL into a path this Volto app can route to.
16
+ *
17
+ * The backend answers with the URL it is served under, which in a split
18
+ * deployment is not the URL the browser is on. Passing that straight to
19
+ * `history.push` would navigate off the frontend and land the user on the
20
+ * backend's own rendering of the Profile.
21
+ *
22
+ * @param url Absolute or relative URL from the backend.
23
+ * @param apiPath The backend's base URL, when it differs from the frontend's.
24
+ * @returns A site-relative path, always beginning with a slash.
25
+ */
26
+ export function toAppPath(url: string, apiPath = ''): string {
27
+ if (!url) {
28
+ return '/';
29
+ }
30
+ let path = url;
31
+ if (apiPath && path.startsWith(apiPath)) {
32
+ path = path.slice(apiPath.length);
33
+ } else {
34
+ // No configured base, or the answer came from somewhere else: fall back to
35
+ // stripping the origin, which is all a path needs.
36
+ const match = /^https?:\/\/[^/]+(\/.*)?$/.exec(path);
37
+ if (match) {
38
+ path = match[1] ?? '/';
39
+ }
40
+ }
41
+ return path.startsWith('/') ? path : `/${path}`;
42
+ }
43
+
44
+ /**
45
+ * Work out where to send a user who has just signed in.
46
+ *
47
+ * Only an `incomplete` Profile diverts them. Every other answer — no Profile,
48
+ * a `complete` one, a `deactivated` one, a site without the extra installed —
49
+ * means "carry on where you were going". Diverting on anything else would
50
+ * trap a user with a deliberately sparse profile in a loop they cannot leave.
51
+ *
52
+ * They land on the **edit form** rather than on the profile. The profile is
53
+ * missing something the site requires, and a read-only page showing them that
54
+ * asks for an extra click to reach the only thing they can do about it.
55
+ *
56
+ * @param profile The `@my-profile` answer, or null before it has loaded.
57
+ * @param fallback Where the user was heading.
58
+ * @param apiPath The backend's base URL, when it differs from the frontend's.
59
+ * @returns The path to navigate to.
60
+ */
61
+ export function afterLogin(
62
+ profile: MyProfile | null | undefined,
63
+ fallback: string,
64
+ apiPath = '',
65
+ ): string {
66
+ if (!profile?.profile || profile.review_state !== 'incomplete') {
67
+ return fallback;
68
+ }
69
+ // Where they were heading is not thrown away, it is parked. `ProfileGate`
70
+ // restores it the moment the profile stops being incomplete, which is what
71
+ // turns "you must fill this in first" into a detour rather than a dead end.
72
+ // In the demo that dead end was a user stranded on the identity provider,
73
+ // mid-way through signing in to a different site.
74
+ rememberReturn(fallback);
75
+ return `${toAppPath(profile.profile, apiPath)}/edit`;
76
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { fromGroupRows, toGroupRows } from './groupmap';
4
+
5
+ describe('toGroupRows', () => {
6
+ it('turns a map into rows', () => {
7
+ expect(toGroupRows({ editors: 'site-editors' })).toEqual([
8
+ { '@id': expect.any(String), group: 'editors', local: 'site-editors' },
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 = toGroupRows({ editors: 'site-editors', staff: 'site-staff' });
16
+
17
+ expect(rows.every((row) => row['@id'])).toBe(true);
18
+ expect(rows[0]['@id']).not.toEqual(rows[1]['@id']);
19
+ });
20
+
21
+ it('keeps the stored order', () => {
22
+ const rows = toGroupRows({ staff: 'site-staff', editors: 'site-editors' });
23
+
24
+ expect(rows.map((row) => row.group)).toEqual(['staff', 'editors']);
25
+ });
26
+
27
+ it('survives an absent map', () => {
28
+ expect(toGroupRows(undefined)).toEqual([]);
29
+ });
30
+ });
31
+
32
+ describe('fromGroupRows', () => {
33
+ it('turns rows back into a map', () => {
34
+ expect(
35
+ fromGroupRows([{ '@id': 'a', group: 'editors', local: 'site-editors' }]),
36
+ ).toEqual({ editors: 'site-editors' });
37
+ });
38
+
39
+ it('drops a row the operator has not finished', () => {
40
+ // A blank row is what pressing Add produces, and it must not be stored.
41
+ // It matters more here than in the property map: a half-written row that
42
+ // reached the backend as a key with an empty value is a mapping that
43
+ // grants nothing while looking on screen like one that grants something.
44
+ const blank = { '@id': 'a', group: '', local: '' };
45
+
46
+ expect(fromGroupRows([blank])).toEqual({});
47
+ expect(fromGroupRows([{ ...blank, group: 'editors' }])).toEqual({});
48
+ expect(fromGroupRows([{ ...blank, local: 'site-editors' }])).toEqual({});
49
+ });
50
+
51
+ it('trims both halves', () => {
52
+ const rows = [
53
+ { '@id': 'a', group: ' editors ', local: ' site-editors ' },
54
+ ];
55
+
56
+ expect(fromGroupRows(rows)).toEqual({ editors: 'site-editors' });
57
+ });
58
+
59
+ it('keeps the last of a repeated provider group', () => {
60
+ // Which is what the object it becomes would do anyway.
61
+ const rows = [
62
+ { '@id': 'a', group: 'editors', local: 'site-editors' },
63
+ { '@id': 'b', group: 'editors', local: 'site-staff' },
64
+ ];
65
+
66
+ expect(fromGroupRows(rows)).toEqual({ editors: 'site-staff' });
67
+ });
68
+
69
+ it('lets two provider groups grant the same local group', () => {
70
+ const rows = [
71
+ { '@id': 'a', group: 'editors', local: 'staff' },
72
+ { '@id': 'b', group: 'authors', local: 'staff' },
73
+ ];
74
+
75
+ expect(fromGroupRows(rows)).toEqual({ editors: 'staff', authors: 'staff' });
76
+ });
77
+
78
+ it('survives absent rows', () => {
79
+ expect(fromGroupRows(undefined)).toEqual({});
80
+ });
81
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Conversion between the stored group map and the rows an editor shows.
3
+ *
4
+ * The mechanics are shared with the property 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
+ *
8
+ * The halves are not symmetric, and the widget reflects that. The provider
9
+ * side is free text, because it is whatever the far end's directory happens
10
+ * to call a group and this site has no way to enumerate it. The local side is
11
+ * a vocabulary, because a group that does not exist here grants nothing --
12
+ * the backend skips it and logs -- and a picker is how that stops being a
13
+ * typo nobody notices.
14
+ * @module helpers/groupmap
15
+ */
16
+
17
+ import { rowConverters } from './rowmap';
18
+
19
+ /** One row of the group mapping editor. */
20
+ export interface GroupMapRow {
21
+ '@id': string;
22
+ group: string;
23
+ local: string;
24
+ }
25
+
26
+ const converters = rowConverters<GroupMapRow>('group', 'local');
27
+
28
+ /**
29
+ * Turn a stored group map into editor rows.
30
+ *
31
+ * @param map Provider-side group name to local group id.
32
+ * @returns One row per entry, in the map's own order.
33
+ */
34
+ export function toGroupRows(
35
+ map: Record<string, string> | undefined,
36
+ ): GroupMapRow[] {
37
+ return converters.toRows(map);
38
+ }
39
+
40
+ /**
41
+ * Turn editor rows back into a stored group map.
42
+ *
43
+ * @param rows Rows from the editor.
44
+ * @returns Provider-side group name to local group id.
45
+ */
46
+ export function fromGroupRows(
47
+ rows: GroupMapRow[] | undefined,
48
+ ): Record<string, string> {
49
+ return converters.fromRows(rows);
50
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Working out what a user can still add, and how.
3
+ * @module helpers/identities
4
+ */
5
+
6
+ /**
7
+ * The driver whose flow is a mailbox rather than a redirect.
8
+ *
9
+ * The login form renders it as an address field instead of a button, because
10
+ * the thing being proven is control of a mailbox and only the person signing
11
+ * in knows which one.
12
+ *
13
+ * It is *not* offered on the sign-in-methods page. The address a magic link
14
+ * proves is whatever was typed, so a free-text box there verifies any mailbox
15
+ * at all -- and a verified address is what a provider account can be
16
+ * auto-attached to. The addresses this site will verify are the ones already
17
+ * on your profile, and that page offers those instead. The backend enforces
18
+ * the same rule in two places, so this constant is a label rather than a
19
+ * gate: `@identities` leaves the provider out of `available`, and
20
+ * `POST @identities` refuses an address that is not yours.
21
+ */
22
+ export const EMAIL_DRIVER = 'email';