@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,640 @@
1
+ /**
2
+ * Shared fixtures and decorators for the stories.
3
+ *
4
+ * Two things every story in this package needs. The data is the shape the
5
+ * backend actually returns, copied from `types` rather than invented, so a
6
+ * story that renders is evidence the component can render what it will really
7
+ * be given. And the connected components need a store: Storybook's preview
8
+ * wraps stories in an `IntlProvider` and a router, but not a redux
9
+ * `Provider`, and half of this add-on reads `useSelector`.
10
+ *
11
+ * The store is inert on purpose. A story is a picture of one state, so
12
+ * dispatching does nothing and the state never changes -- which is also what
13
+ * makes a loading state or an error state something you can simply ask for.
14
+ * @module stories/fixtures
15
+ */
16
+ import React from 'react';
17
+ import type { ReactNode } from 'react';
18
+ import { Provider } from 'react-redux';
19
+
20
+ import LoginPanel from '../components/Login/LoginPanel';
21
+
22
+ import type {
23
+ ConfiguredProvider,
24
+ ConsentRequest,
25
+ Driver,
26
+ Identity,
27
+ LoginProvider,
28
+ OAuthClient,
29
+ OAuthGrants,
30
+ SigningKeyRing,
31
+ UserAccount,
32
+ UserProfile,
33
+ ProfileEmail,
34
+ } from '../types';
35
+
36
+ /** A request that has finished with data. */
37
+ export const LOADED = { loading: false, loaded: true, error: null };
38
+
39
+ /** A request still in flight. */
40
+ export const LOADING = { loading: true, loaded: false, error: null };
41
+
42
+ /** A request that was refused. */
43
+ export const FAILED = { loading: false, loaded: false, error: { status: 401 } };
44
+
45
+ export const GOOGLE: LoginProvider = {
46
+ '@id': '/@login-providers/google',
47
+ id: 'google',
48
+ title: 'Google',
49
+ driver: 'google',
50
+ };
51
+
52
+ export const GITHUB: LoginProvider = {
53
+ '@id': '/@login-providers/github',
54
+ id: 'github',
55
+ title: 'GitHub',
56
+ driver: 'github',
57
+ };
58
+
59
+ export const KEYCLOAK: LoginProvider = {
60
+ '@id': '/@login-providers/keycloak',
61
+ id: 'keycloak',
62
+ title: 'Sign in with Keycloak',
63
+ driver: 'oidc-generic',
64
+ };
65
+
66
+ export const EMAIL: LoginProvider = {
67
+ '@id': '/@login-providers/email',
68
+ id: 'email',
69
+ title: 'Email',
70
+ driver: 'email',
71
+ };
72
+
73
+ export const PROVIDERS = [GOOGLE, GITHUB, KEYCLOAK];
74
+
75
+ /**
76
+ * A provider carrying the look an operator gave it.
77
+ *
78
+ * The icon is a real SVG rather than a placeholder, because the point of the
79
+ * story is that the button is drawn from it: a stand-in string would render
80
+ * an empty box and prove nothing.
81
+ */
82
+ export const STYLED: LoginProvider = {
83
+ '@id': '/@login-providers/acme',
84
+ id: 'acme',
85
+ title: 'Acme SSO',
86
+ driver: 'oidc-generic',
87
+ icon:
88
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">' +
89
+ '<path d="M8 1l7 13H1z"/></svg>',
90
+ background_color: '#4b3f72',
91
+ foreground_color: '#ffffff',
92
+ };
93
+
94
+ /** A profile's addresses: one proved, one not. */
95
+ export const PROFILE_EMAILS: ProfileEmail[] = [
96
+ { address: 'erico@plone.org', verified: true, preferred: true },
97
+ { address: 'erico@example.com', verified: false, preferred: false },
98
+ ];
99
+
100
+ export const IDENTITIES: Identity[] = [
101
+ {
102
+ '@id': '/@identities/google:1234',
103
+ provider: 'google',
104
+ subject: '1234567890',
105
+ title: 'Google',
106
+ created: '2026-01-14T09:12:00+00:00',
107
+ last_login: '2026-08-21T18:03:00+00:00',
108
+ can_unlink: true,
109
+ },
110
+ {
111
+ '@id': '/@identities/github:99',
112
+ provider: 'github',
113
+ subject: '99',
114
+ title: 'GitHub',
115
+ created: '2026-03-02T11:40:00+00:00',
116
+ last_login: null,
117
+ can_unlink: true,
118
+ },
119
+ ];
120
+
121
+ /** The last way in: unlinking it would lock the user out. */
122
+ export const ONLY_IDENTITY: Identity[] = [
123
+ { ...IDENTITIES[0], can_unlink: false },
124
+ ];
125
+
126
+ /**
127
+ * What one entry of `@identity-drivers` sends: `IOIDCSettings`, serialized.
128
+ *
129
+ * An ordinary JSON schema, because that is what the backend produces now --
130
+ * `plone.restapi`'s own `get_fieldsets` / `get_jsonschema_properties` /
131
+ * `get_fieldset_infos`, the same three calls that answer `@controlpanels`.
132
+ * Titles arrive translated and widgets arrive decided, which is why nothing
133
+ * here carries a `secret` flag or an `order`: a secret is a `Password` field
134
+ * and the order is the order the properties are in.
135
+ */
136
+ export const OIDC_DRIVER: Driver = {
137
+ id: 'oidc-generic',
138
+ title: 'OpenID Connect',
139
+ schema: {
140
+ properties: {
141
+ issuer: {
142
+ type: 'string',
143
+ title: 'Issuer URL',
144
+ description:
145
+ 'Discovery is fetched from <issuer>/.well-known/openid-configuration.',
146
+ },
147
+ client_id: {
148
+ type: 'string',
149
+ title: 'Client ID',
150
+ description: 'The identifier this provider issued for this site.',
151
+ },
152
+ client_secret: {
153
+ type: 'string',
154
+ title: 'Client secret',
155
+ description: 'Write-only. It is never sent back by any endpoint here.',
156
+ widget: 'password',
157
+ },
158
+ scope: {
159
+ type: 'array',
160
+ title: 'Scope',
161
+ description: 'One permission per entry.',
162
+ widget: 'token',
163
+ },
164
+ userid_source: {
165
+ type: 'string',
166
+ title: 'Userid taken from',
167
+ choices: [
168
+ ['uuid', 'A random id'],
169
+ ['username', "The provider's username"],
170
+ ['email', 'The email address'],
171
+ ['subject', "The provider's subject identifier"],
172
+ ],
173
+ },
174
+ group_claim: {
175
+ type: 'string',
176
+ title: 'Groups arrive in the claim',
177
+ description:
178
+ 'Which claim carries the group names this provider asserts.',
179
+ },
180
+ },
181
+ required: ['issuer', 'client_id', 'client_secret'],
182
+ fieldsets: [
183
+ {
184
+ id: 'default',
185
+ title: 'Default',
186
+ fields: [
187
+ 'issuer',
188
+ 'client_id',
189
+ 'client_secret',
190
+ 'scope',
191
+ 'userid_source',
192
+ 'group_claim',
193
+ ],
194
+ },
195
+ ],
196
+ },
197
+ };
198
+
199
+ /**
200
+ * A driver whose providers have no groups.
201
+ *
202
+ * The real GitHub driver is this case, and the difference is worth having in
203
+ * the fixtures: the group mapping is offered for a driver that declares a
204
+ * group claim and hidden for one that does not, so a story showing only the
205
+ * first would never show the second. `IGitHubSettings` extends the OAuth2
206
+ * base and adds nothing, so the absence here is the absence there.
207
+ */
208
+ const NO_GROUPS_SCHEMA: Driver['schema'] = {
209
+ properties: Object.fromEntries(
210
+ Object.entries(OIDC_DRIVER.schema.properties ?? {}).filter(
211
+ ([name]) => name !== 'group_claim' && name !== 'issuer',
212
+ ),
213
+ ),
214
+ required: ['client_id', 'client_secret'],
215
+ fieldsets: [
216
+ {
217
+ id: 'default',
218
+ title: 'Default',
219
+ fields: ['client_id', 'client_secret', 'scope', 'userid_source'],
220
+ },
221
+ ],
222
+ };
223
+
224
+ export const DRIVERS: Driver[] = [
225
+ OIDC_DRIVER,
226
+ { id: 'google', title: 'Google', schema: OIDC_DRIVER.schema },
227
+ { id: 'github', title: 'GitHub', schema: NO_GROUPS_SCHEMA },
228
+ ];
229
+
230
+ /**
231
+ * What `@identity-providers` sends alongside the listing: `IProviderRecords`.
232
+ *
233
+ * The provider's own half of the form -- everything true of a provider
234
+ * whatever its driver. The panel renders this and the chosen driver's schema
235
+ * together, which is why a story needs both.
236
+ */
237
+ export const PROVIDER_SCHEMA = {
238
+ type: 'object',
239
+ properties: {
240
+ // Served, and dropped again by `providerSchema`: a provider's storage
241
+ // has a driver and two maps, and none of the three is renderable as
242
+ // described. Kept here because a fixture that quietly leaves them out
243
+ // would stop the stories from showing what the composition does.
244
+ driver: { type: 'string', title: 'Driver' },
245
+ propertymap: { type: 'object', title: 'Property map' },
246
+ groupmap: { type: 'object', title: 'Group map' },
247
+ title: { type: 'string', title: 'Title' },
248
+ enabled: { type: 'boolean', title: 'Enabled' },
249
+ show_in_login: { type: 'boolean', title: 'Show on the login screen' },
250
+ order: { type: 'integer', title: 'Order' },
251
+ icon: { type: 'string', title: 'Icon', widget: 'provider_icon' },
252
+ background_color: {
253
+ type: 'string',
254
+ title: 'Background colour',
255
+ widget: 'color_picker',
256
+ },
257
+ foreground_color: {
258
+ type: 'string',
259
+ title: 'Foreground colour',
260
+ widget: 'color_picker',
261
+ },
262
+ },
263
+ required: [],
264
+ fieldsets: [
265
+ {
266
+ id: 'default',
267
+ title: 'Default',
268
+ fields: [
269
+ 'driver',
270
+ 'title',
271
+ 'enabled',
272
+ 'show_in_login',
273
+ 'order',
274
+ 'propertymap',
275
+ 'groupmap',
276
+ ],
277
+ },
278
+ {
279
+ id: 'style',
280
+ title: 'Style',
281
+ fields: ['icon', 'background_color', 'foreground_color'],
282
+ },
283
+ ],
284
+ };
285
+
286
+ export const CONFIGURED: ConfiguredProvider[] = [
287
+ {
288
+ '@id': '/@identity-providers/keycloak',
289
+ id: 'keycloak',
290
+ driver: 'oidc-generic',
291
+ title: 'Sign in with Keycloak',
292
+ enabled: true,
293
+ show_in_login: true,
294
+ config: {
295
+ client_id: 'plone',
296
+ client_secret: '••••••••',
297
+ issuer: 'https://id.example.org/realms/main',
298
+ scope: 'openid email profile',
299
+ },
300
+ propertymap: {
301
+ preferred_username: 'username',
302
+ 'address.formatted': 'location',
303
+ },
304
+ groupmap: {
305
+ 'plone-editors': 'Site Administrators',
306
+ 'plone-staff': 'Reviewers',
307
+ },
308
+ },
309
+ {
310
+ '@id': '/@identity-providers/github',
311
+ id: 'github',
312
+ driver: 'github',
313
+ title: 'GitHub',
314
+ enabled: false,
315
+ show_in_login: false,
316
+ config: { client_id: 'Iv1.0123456789abcdef', client_secret: '••••••••' },
317
+ propertymap: {},
318
+ groupmap: {},
319
+ },
320
+ ];
321
+
322
+ /**
323
+ * The vocabulary the property-map editor reads, already loaded.
324
+ *
325
+ * Served by the backend from the site's live user schema, so a story shows
326
+ * a representative subset rather than a list this file owns.
327
+ */
328
+ export const USER_FIELDS_STATE = {
329
+ 'pas.plugins.identity.UserFields': {
330
+ loaded: true,
331
+ loading: false,
332
+ items: [
333
+ { value: 'description', label: 'Biography' },
334
+ { value: 'email', label: 'Email' },
335
+ { value: 'fullname', label: 'Full Name' },
336
+ { value: 'home_page', label: 'Home page' },
337
+ { value: 'location', label: 'Location' },
338
+ { value: 'username', label: 'Username' },
339
+ ],
340
+ itemsTotal: 6,
341
+ },
342
+ };
343
+
344
+ /**
345
+ * The vocabulary the group-map editor reads, already loaded.
346
+ *
347
+ * Every group PAS knows on the site, which is why the local half of a group
348
+ * mapping is a picker: a group that does not exist here grants nothing, and
349
+ * that is a typo worth catching in the form rather than in a log line.
350
+ */
351
+ export const GROUPS_STATE = {
352
+ 'pas.plugins.identity.Groups': {
353
+ loaded: true,
354
+ loading: false,
355
+ items: [
356
+ { value: 'Administrators', label: 'Administrators' },
357
+ { value: 'Reviewers', label: 'Reviewers' },
358
+ { value: 'Site Administrators', label: 'Site Administrators' },
359
+ ],
360
+ itemsTotal: 3,
361
+ },
362
+ };
363
+
364
+ export const CLIENT: OAuthClient = {
365
+ '@id': '/@identity-clients/intranet',
366
+ client_id: 'intranet',
367
+ title: 'Intranet',
368
+ redirect_uris: ['https://intranet.example.org/login-identity'],
369
+ grant_types: ['authorization_code', 'refresh_token'],
370
+ scope: ['openid', 'email', 'profile'],
371
+ auth_method: 'client_secret_post',
372
+ public: false,
373
+ enabled: true,
374
+ service_user: '',
375
+ };
376
+
377
+ export const CLIENTS: OAuthClient[] = [
378
+ CLIENT,
379
+ {
380
+ ...CLIENT,
381
+ '@id': '/@identity-clients/kiosk',
382
+ client_id: 'kiosk',
383
+ title: 'Lobby kiosk',
384
+ auth_method: 'none',
385
+ public: true,
386
+ enabled: false,
387
+ },
388
+ ];
389
+
390
+ /** A client as it comes back from the one response that carries a secret. */
391
+ export const MINTED_CLIENT: OAuthClient = {
392
+ ...CLIENT,
393
+ secret: 'Yb3nT7qk-2sV1pE0xR8wL4mZ6aQ9cJ5dN0gH2fK1uS',
394
+ notice:
395
+ 'This secret is shown once and is not recoverable. Store it before leaving this page.',
396
+ };
397
+
398
+ /** The applications a user has authorized, as `@oauth-grants` lists them. */
399
+ export const GRANTS: OAuthGrants = {
400
+ '@id': 'http://id.localhost/@oauth-grants',
401
+ access_token_ttl: 900,
402
+ items: [
403
+ {
404
+ '@id': 'http://id.localhost/@oauth-grants/demo-rp',
405
+ client_id: 'demo-rp',
406
+ title: 'Plone Content Site',
407
+ registered: true,
408
+ enabled: true,
409
+ granted_at: '2026-08-24T09:15:00+00:00',
410
+ scopes: [
411
+ { id: 'openid', claims: [] },
412
+ {
413
+ id: 'profile',
414
+ claims: ['name', 'preferred_username', 'picture', 'description'],
415
+ },
416
+ { id: 'email', claims: ['email', 'email_verified'] },
417
+ ],
418
+ },
419
+ {
420
+ '@id': 'http://id.localhost/@oauth-grants/reporting',
421
+ client_id: 'reporting',
422
+ title: 'Nightly reporting job',
423
+ registered: true,
424
+ enabled: true,
425
+ granted_at: '2026-06-02T22:40:00+00:00',
426
+ scopes: [{ id: 'openid', claims: [] }],
427
+ },
428
+ ],
429
+ };
430
+
431
+ /** A pending authorization request, as `@oauth-consent` describes one. */
432
+ export const CONSENT_REQUEST: ConsentRequest = {
433
+ '@id': 'http://id.localhost/@oauth-consent',
434
+ client: { id: 'demo-rp', title: 'Plone Content Site' },
435
+ user: { id: 'alice', label: 'Alice Liddell' },
436
+ scopes: [
437
+ { id: 'openid', claims: [] },
438
+ { id: 'profile', claims: ['name', 'preferred_username', 'picture'] },
439
+ { id: 'email', claims: ['email', 'email_verified'] },
440
+ ],
441
+ authorize_url: 'http://id.localhost/@@oauth-authorize',
442
+ params: {
443
+ response_type: 'code',
444
+ client_id: 'demo-rp',
445
+ redirect_uri: 'http://plone.localhost/login-identity',
446
+ scope: 'openid profile email',
447
+ state: 'a1b2c3',
448
+ },
449
+ authenticator: 'a-plone-protect-token',
450
+ };
451
+
452
+ export const KEYRING: SigningKeyRing = {
453
+ '@id': '/@identity-keys',
454
+ algorithm: 'RS256',
455
+ ring_size: 2,
456
+ jwks_uri: 'https://example.org/@@oauth-jwks',
457
+ items_total: 2,
458
+ items: [
459
+ { kid: 'q7Yk2mVx', active: true },
460
+ { kid: 'b1Rn8dTs', active: false },
461
+ ],
462
+ };
463
+
464
+ /**
465
+ * Wrap a story in a redux store holding exactly the state it wants.
466
+ *
467
+ * @param state The store's whole state, as the component's selectors read it.
468
+ * @returns A Storybook decorator.
469
+ */
470
+ /**
471
+ * One account as `@user-account` serves it.
472
+ *
473
+ * Shared, because two stories show it: the panel on its own, and the control
474
+ * panel page that fetches it.
475
+ */
476
+ export const USER_ACCOUNT: UserAccount = {
477
+ '@id': '/@user-account/erico',
478
+ userid: 'erico',
479
+ fullname: 'Érico Andrei',
480
+ profile_url: '/identity-profiles/erico',
481
+ identities: [
482
+ {
483
+ provider: 'github',
484
+ title: 'GitHub',
485
+ subject: '99',
486
+ created: '2026-03-02T11:40:00+00:00',
487
+ last_login: '2026-08-21T18:03:00+00:00',
488
+ provider_configured: true,
489
+ provider_enabled: true,
490
+ groups: ['site-editors'],
491
+ },
492
+ {
493
+ provider: 'email',
494
+ title: 'Email',
495
+ subject: 'erico@plone.org',
496
+ created: '2026-01-14T09:12:00+00:00',
497
+ last_login: null,
498
+ provider_configured: true,
499
+ provider_enabled: true,
500
+ groups: [],
501
+ },
502
+ ],
503
+ emails: [
504
+ { address: 'erico@plone.org', verified: true, preferred: true },
505
+ { address: 'erico@example.com', verified: false, preferred: false },
506
+ ],
507
+ last_authenticated: '2026-08-21T18:03:00+00:00',
508
+ events_total: 2,
509
+ events: [
510
+ {
511
+ event: 'authenticated',
512
+ provider: 'github',
513
+ success: true,
514
+ timestamp: '2026-08-21T18:03:00+00:00',
515
+ detail: {},
516
+ },
517
+ {
518
+ event: 'magic-link-sent',
519
+ provider: 'email',
520
+ success: true,
521
+ timestamp: '2026-08-20T09:00:00+00:00',
522
+ detail: {},
523
+ },
524
+ ],
525
+ };
526
+
527
+ /**
528
+ * Render a story inside the login card, at the real page's dimensions.
529
+ *
530
+ * `LoginForm`, `PasswordForm` and `MagicLinkForm` are never seen anywhere but
531
+ * inside `LoginPanel`, and the panel is what sizes them: the card is
532
+ * `--identity-login-width` wide and the forms lay themselves out against
533
+ * that. On Storybook's full-width canvas they stretched to whatever the
534
+ * viewport was, so a story could look fine and the page wrong -- and two
535
+ * forms meant to be indistinguishable could not be compared at all.
536
+ *
537
+ * The real component rather than a `<div>` of the same width, so the stories
538
+ * cannot drift from the page: a change to the card's width or padding shows
539
+ * up here without anybody remembering to copy it.
540
+ *
541
+ * @param description The strip under the heading, which names what is below
542
+ * -- the real page picks between two sentences depending on what a site has
543
+ * configured, so a story showing only the password form passes the other.
544
+ * @returns A Storybook decorator.
545
+ */
546
+ export function withLoginCard(
547
+ description = 'Choose how you would like to sign in.',
548
+ ) {
549
+ const Decorator = (Story: () => ReactNode) => (
550
+ <LoginPanel title="Log in" description={description}>
551
+ {Story()}
552
+ </LoginPanel>
553
+ );
554
+ return Decorator;
555
+ }
556
+
557
+ /**
558
+ * The store slices Volto's own chrome reads, which no story is about.
559
+ *
560
+ * Five pages here portal Volto's `Toolbar`, and it selects these four out of
561
+ * the store whatever the page is. A story that left them out did not render
562
+ * at all -- the failure was a TypeError from inside Volto, which reads as the
563
+ * add-on being broken rather than as a fixture missing furniture.
564
+ *
565
+ * Exactly four, established by removing each in turn: `apierror`, `intl` and
566
+ * `workflow` were guesses and are not read.
567
+ */
568
+ export const VOLTO_CHROME = {
569
+ actions: { actions: {} },
570
+ content: { data: null },
571
+ types: { types: [] },
572
+ userSession: { token: null },
573
+ };
574
+
575
+ /**
576
+ * A store for a story, over the chrome every page needs.
577
+ *
578
+ * The story's own state wins, so a story about being signed in can say so by
579
+ * passing its own `userSession`.
580
+ */
581
+ export function withStore(state: Record<string, unknown>) {
582
+ const merged = { ...VOLTO_CHROME, ...state };
583
+ const store = {
584
+ getState: () => merged,
585
+ dispatch: (action: unknown) => action,
586
+ subscribe: () => () => {},
587
+ };
588
+ const Decorator = (Story: () => ReactNode) => (
589
+ <Provider store={store as never}>{Story()}</Provider>
590
+ );
591
+ return Decorator;
592
+ }
593
+
594
+ /**
595
+ * A portrait, inline.
596
+ *
597
+ * A data URI rather than a URL: a story has to render the same whether or
598
+ * not Storybook can reach the network, and a portrait that silently 404s
599
+ * would show the fallback while claiming to show the picture.
600
+ */
601
+ export const PORTRAIT =
602
+ 'data:image/svg+xml;utf8,' +
603
+ encodeURIComponent(
604
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
605
+ <rect width="96" height="96" fill="#0083be"/>
606
+ <circle cx="48" cy="36" r="18" fill="#bfe3f2"/>
607
+ <path d="M12 96c0-20 16-32 36-32s36 12 36 32z" fill="#bfe3f2"/>
608
+ </svg>`,
609
+ );
610
+
611
+ /** The signed-in user, as `@users/<userid>` describes them. */
612
+ export const USER: UserProfile = {
613
+ '@id': 'https://example.org/@users/alice',
614
+ id: 'alice',
615
+ username: 'alice@example.org',
616
+ fullname: 'Alice Liddell',
617
+ email: 'alice@example.org',
618
+ portrait: null,
619
+ source: 'identity_profile',
620
+ identities: [],
621
+ profile_url: 'https://example.org/identity-profiles/alice',
622
+ };
623
+
624
+ /**
625
+ * Wrap a story in a store holding one signed-in user.
626
+ *
627
+ * The common case for anything in the user menu, which is why it is here
628
+ * rather than repeated per story.
629
+ *
630
+ * @param user The user, or null for an anonymous visitor.
631
+ * @returns A Storybook decorator.
632
+ */
633
+ export function withUser(user: Partial<UserProfile> | null) {
634
+ return withStore({
635
+ userProfile: {
636
+ ...LOADED,
637
+ data: user === null ? null : { ...USER, ...user },
638
+ },
639
+ });
640
+ }