@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,62 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { useridFromToken } from './token';
4
+
5
+ /**
6
+ * Build a JWT-shaped string around a payload.
7
+ *
8
+ * Only the middle segment is ever read, so the other two are noise on
9
+ * purpose: anything that started depending on them would be a bug.
10
+ */
11
+ function tokenFor(payload: unknown): string {
12
+ const body = Buffer.from(JSON.stringify(payload))
13
+ .toString('base64')
14
+ .replace(/\+/g, '-')
15
+ .replace(/\//g, '_')
16
+ .replace(/=+$/, '');
17
+ return `header.${body}.signature`;
18
+ }
19
+
20
+ describe('useridFromToken', () => {
21
+ it('reads the subject', () => {
22
+ expect(useridFromToken(tokenFor({ sub: 'alice', exp: 1 }))).toBe('alice');
23
+ });
24
+
25
+ it('handles a payload whose base64 needs padding', () => {
26
+ // A JWT drops the `=` padding, so a payload whose length is not a
27
+ // multiple of four is the normal case rather than an edge one.
28
+ const userid = 'a'.repeat(7);
29
+
30
+ expect(useridFromToken(tokenFor({ sub: userid }))).toBe(userid);
31
+ });
32
+
33
+ it('handles base64url characters', () => {
34
+ // `-` and `_` stand in for `+` and `/`; decoding without translating
35
+ // them back yields mojibake rather than an error, which is the kind of
36
+ // bug that only shows up for some users.
37
+ const userid = 'ürico?andrei>plone';
38
+
39
+ expect(useridFromToken(tokenFor({ sub: userid }))).toBe(userid);
40
+ });
41
+
42
+ it('answers nobody when there is no token', () => {
43
+ expect(useridFromToken(undefined)).toBe('');
44
+ expect(useridFromToken(null)).toBe('');
45
+ expect(useridFromToken('')).toBe('');
46
+ });
47
+
48
+ it('answers nobody for something that is not a JWT', () => {
49
+ // This runs inside a component rendered on every page, so a malformed
50
+ // token has to be an empty answer rather than an exception.
51
+ expect(useridFromToken('not-a-jwt')).toBe('');
52
+ expect(useridFromToken('a.b.c')).toBe('');
53
+ });
54
+
55
+ it('answers nobody when the payload carries no subject', () => {
56
+ expect(useridFromToken(tokenFor({ exp: 1 }))).toBe('');
57
+ });
58
+
59
+ it('answers nobody when the subject is not a string', () => {
60
+ expect(useridFromToken(tokenFor({ sub: 42 }))).toBe('');
61
+ });
62
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Read the caller's userid out of the session token.
3
+ *
4
+ * Volto keeps the JWT in `state.userSession.token` and nothing else in the
5
+ * store names the current user until something has fetched them, so the
6
+ * token is where a userid comes from before the first request.
7
+ *
8
+ * This does **not** verify the token, and must never be used as if it did.
9
+ * The signature is the backend's business: every request carrying this token
10
+ * is checked there, and a forged one buys nothing but a userid this frontend
11
+ * will then ask the server about and be refused.
12
+ * @module helpers/token
13
+ */
14
+
15
+ /**
16
+ * Decode one base64url segment of a JWT.
17
+ *
18
+ * @param segment The segment, base64url encoded and unpadded.
19
+ * @returns The decoded text, or an empty string when it is not decodable.
20
+ */
21
+ function decodeSegment(segment: string): string {
22
+ const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
23
+ const padded = base64.padEnd(
24
+ base64.length + ((4 - (base64.length % 4)) % 4),
25
+ '=',
26
+ );
27
+ if (typeof atob !== 'function') {
28
+ // Both halves of Volto have it -- the browser always, and Node since 16,
29
+ // which is well below what Volto supports. Deliberately not falling back
30
+ // to `Buffer`: naming it here makes webpack pull a polyfill for the whole
31
+ // module into the client bundle, and the build then fails on a missing
32
+ // dependency rather than on anything to do with tokens.
33
+ return '';
34
+ }
35
+ try {
36
+ // `atob` answers one character per *byte*, latin-1 -- so a userid with
37
+ // anything outside ASCII in it arrives as mojibake unless the bytes are
38
+ // decoded as the UTF-8 they are. A userid can hold one: `userid_source`
39
+ // may make it the provider's username or an email address.
40
+ const binary = atob(padded);
41
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
42
+ return new TextDecoder().decode(bytes);
43
+ } catch {
44
+ return '';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Return the userid a session token was issued for.
50
+ *
51
+ * @param token The JWT from `state.userSession.token`, if there is one.
52
+ * @returns The `sub` claim, or an empty string when there is no usable token.
53
+ */
54
+ export function useridFromToken(token: string | undefined | null): string {
55
+ const segment = (token ?? '').split('.')[1];
56
+ if (!segment) {
57
+ return '';
58
+ }
59
+ const decoded = decodeSegment(segment);
60
+ if (!decoded) {
61
+ return '';
62
+ }
63
+ try {
64
+ const claims = JSON.parse(decoded);
65
+ return typeof claims?.sub === 'string' ? claims.sub : '';
66
+ } catch {
67
+ // A token that is not a JWT at all. Answering "nobody" is right: the
68
+ // alternative is throwing inside a component that renders on every page.
69
+ return '';
70
+ }
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { ConfigType } from '@plone/registry';
2
+ import installReducers from './config/reducers';
3
+ import installRoutes from './config/routes';
4
+ import installSettings from './config/settings';
5
+ import installMenu from './config/menu';
6
+ import installViews from './config/views';
7
+ import installWidgets from './config/widgets';
8
+
9
+ import './styles.css';
10
+
11
+ function applyConfig(config: ConfigType) {
12
+ installSettings(config);
13
+ installReducers(config);
14
+ installRoutes(config);
15
+ installMenu(config);
16
+ installViews(config);
17
+ installWidgets(config);
18
+
19
+ return config;
20
+ }
21
+
22
+ export default applyConfig;
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Reducers for the identity login flows.
3
+ *
4
+ * One factory rather than five near-identical reducers: they all track the
5
+ * same request lifecycle and differ only in what they keep from the answer.
6
+ * @module reducers
7
+ */
8
+
9
+ import {
10
+ COMPLETE_CALLBACK,
11
+ CONFIRM_MAGIC_LINK,
12
+ CREATE_CLIENT,
13
+ CREATE_PROVIDER,
14
+ DELETE_CLIENT,
15
+ DELETE_PROVIDER,
16
+ GET_CONSENT_REQUEST,
17
+ GET_MY_PROFILE,
18
+ SET_PREFERRED_EMAIL,
19
+ GET_USER_ACCOUNT,
20
+ LIST_GRANTS,
21
+ LIST_GROUP_MEMBERS,
22
+ WITHDRAW_GRANT,
23
+ LIST_CLIENTS,
24
+ LIST_DRIVERS,
25
+ LIST_KEYS,
26
+ GET_USER_PROFILE,
27
+ LIST_IDENTITIES,
28
+ LIST_LOGIN_PROVIDERS,
29
+ LIST_PROVIDERS,
30
+ ROTATE_CLIENT_SECRET,
31
+ ROTATE_KEY,
32
+ SEND_MAGIC_LINK,
33
+ START_LINKING,
34
+ START_PROVIDER_LOGIN,
35
+ TEST_PROVIDER,
36
+ UNLINK_IDENTITY,
37
+ UPDATE_CLIENT,
38
+ UPDATE_PROVIDER,
39
+ } from '../constants/ActionTypes';
40
+ import type {
41
+ AuthorizeRedirect,
42
+ ConfiguredProvider,
43
+ ConsentRequest,
44
+ ConnectionCheck,
45
+ Driver,
46
+ GroupMembers,
47
+ Identity,
48
+ LoginProvider,
49
+ MyProfile,
50
+ UserAccount,
51
+ UserProfile,
52
+ OAuthClient,
53
+ OAuthGrants,
54
+ RequestState,
55
+ SigningKeyRing,
56
+ TokenResponse,
57
+ } from '../types';
58
+
59
+ type Action = { type: string; result?: unknown; error?: unknown };
60
+
61
+ const initial: RequestState = { loading: false, loaded: false, error: null };
62
+
63
+ /**
64
+ * Build a reducer tracking one request.
65
+ *
66
+ * @param actionType The base action type.
67
+ * @param extract What to keep out of a successful result.
68
+ * @param empty The value of that key before anything has loaded.
69
+ */
70
+ function requestReducer<T>(
71
+ actionType: string,
72
+ extract: (result: any) => T,
73
+ empty: T,
74
+ alsoFrom?: { actionType: string; extract: (result: any) => T | undefined },
75
+ ) {
76
+ const initialState = { ...initial, data: empty };
77
+ return function reducer(state = initialState, action: Action = { type: '' }) {
78
+ // A second action may carry this same data as an expanded component. It
79
+ // only ever *fills*: the request it belongs to is somebody else's, so its
80
+ // pending and failure states say nothing about this one, and a response
81
+ // that did not carry the component must leave what is here alone.
82
+ if (alsoFrom && action.type === `${alsoFrom.actionType}_SUCCESS`) {
83
+ const carried = alsoFrom.extract(action.result);
84
+ return carried === undefined
85
+ ? state
86
+ : {
87
+ ...state,
88
+ loading: false,
89
+ loaded: true,
90
+ error: null,
91
+ data: carried,
92
+ };
93
+ }
94
+ switch (action.type) {
95
+ case `${actionType}_PENDING`:
96
+ // The previous answer is cleared here as well as on success: leaving
97
+ // it in place makes a second attempt look like it has already
98
+ // succeeded, which for a redirect action means navigating away with
99
+ // last time's URL.
100
+ return { ...initialState, loading: true };
101
+ case `${actionType}_SUCCESS`:
102
+ return {
103
+ ...state,
104
+ loading: false,
105
+ loaded: true,
106
+ error: null,
107
+ data: extract(action.result),
108
+ };
109
+ case `${actionType}_FAIL`:
110
+ return { ...initialState, error: action.error ?? true };
111
+ default:
112
+ return state;
113
+ }
114
+ };
115
+ }
116
+
117
+ export const loginProviders = requestReducer<LoginProvider[]>(
118
+ LIST_LOGIN_PROVIDERS,
119
+ (result) => result?.items ?? [],
120
+ [],
121
+ {
122
+ // The identities listing can carry the same providers as an expanded
123
+ // component, which is how the identities page loads in one request.
124
+ actionType: LIST_IDENTITIES,
125
+ extract: (result) => result?.['@components']?.['login-providers']?.items,
126
+ },
127
+ );
128
+
129
+ export const providerLogin = requestReducer<AuthorizeRedirect | null>(
130
+ START_PROVIDER_LOGIN,
131
+ (result) => result ?? null,
132
+ null,
133
+ );
134
+
135
+ export const identityCallback = requestReducer<TokenResponse | null>(
136
+ COMPLETE_CALLBACK,
137
+ (result) => result ?? null,
138
+ null,
139
+ );
140
+
141
+ export const magicLinkSend = requestReducer<boolean>(
142
+ SEND_MAGIC_LINK,
143
+ (result) => Boolean(result?.sent),
144
+ false,
145
+ );
146
+
147
+ export const magicLinkConfirm = requestReducer<TokenResponse | null>(
148
+ CONFIRM_MAGIC_LINK,
149
+ (result) => result ?? null,
150
+ null,
151
+ );
152
+
153
+ export const userProfile = requestReducer<UserProfile | null>(
154
+ GET_USER_PROFILE,
155
+ (result) => result ?? null,
156
+ null,
157
+ );
158
+
159
+ export const identities = requestReducer<Identity[]>(
160
+ LIST_IDENTITIES,
161
+ (result) => result?.items ?? [],
162
+ [],
163
+ );
164
+
165
+ /**
166
+ * What the caller could still attach to their account.
167
+ *
168
+ * The backend's own answer rather than a filter applied here, and a different
169
+ * question from `loginProviders`: that one is what the login screen offers,
170
+ * this one is every *enabled* provider the caller has not linked. A provider
171
+ * an operator has taken off the login page is still one an existing user may
172
+ * attach, which is exactly what the two settings exist to distinguish.
173
+ */
174
+ export const linkableProviders = requestReducer<LoginProvider[]>(
175
+ LIST_IDENTITIES,
176
+ (result) => result?.available ?? [],
177
+ [],
178
+ );
179
+
180
+ export const groupMembers = requestReducer<GroupMembers | null>(
181
+ LIST_GROUP_MEMBERS,
182
+ (result) => result ?? null,
183
+ null,
184
+ );
185
+
186
+ export const userAccount = requestReducer<UserAccount | null>(
187
+ GET_USER_ACCOUNT,
188
+ (result) => result ?? null,
189
+ null,
190
+ );
191
+
192
+ export const identityLinking = requestReducer<AuthorizeRedirect | null>(
193
+ START_LINKING,
194
+ (result) => result ?? null,
195
+ null,
196
+ );
197
+
198
+ export const identityUnlink = requestReducer<boolean>(
199
+ UNLINK_IDENTITY,
200
+ () => true,
201
+ false,
202
+ );
203
+
204
+ export const identityDrivers = requestReducer<Driver[]>(
205
+ LIST_DRIVERS,
206
+ (result) => result?.items ?? [],
207
+ [],
208
+ );
209
+
210
+ export const configuredProviders = requestReducer<ConfiguredProvider[]>(
211
+ LIST_PROVIDERS,
212
+ (result) => result?.items ?? [],
213
+ [],
214
+ );
215
+
216
+ // The provider form's own schema, served beside the listing. Its own slice
217
+ // rather than a field on `configuredProviders`, because the panel asks for it
218
+ // on mount and Volto's `Form` reads `schema.fieldsets` on the first render --
219
+ // a listing that arrived without one would crash rather than render empty.
220
+ export const providerFormSchema = requestReducer<Record<string, any> | null>(
221
+ LIST_PROVIDERS,
222
+ (result) => result?.schema ?? null,
223
+ null,
224
+ );
225
+
226
+ export const clientFormSchema = requestReducer<Record<string, any> | null>(
227
+ LIST_CLIENTS,
228
+ (result) => result?.schema ?? null,
229
+ null,
230
+ );
231
+
232
+ export const providerCreate = requestReducer<ConfiguredProvider | null>(
233
+ CREATE_PROVIDER,
234
+ (result) => result ?? null,
235
+ null,
236
+ );
237
+
238
+ export const providerUpdate = requestReducer<boolean>(
239
+ UPDATE_PROVIDER,
240
+ () => true,
241
+ false,
242
+ );
243
+
244
+ export const providerDelete = requestReducer<boolean>(
245
+ DELETE_PROVIDER,
246
+ () => true,
247
+ false,
248
+ );
249
+
250
+ export const providerTest = requestReducer<ConnectionCheck | null>(
251
+ TEST_PROVIDER,
252
+ (result) => result ?? null,
253
+ null,
254
+ );
255
+
256
+ export const myProfile = requestReducer<MyProfile | null>(
257
+ GET_MY_PROFILE,
258
+ (result) => result ?? null,
259
+ null,
260
+ );
261
+
262
+ // A `PATCH` on content answers 204 with no body, so there is nothing to keep:
263
+ // this reducer exists for `loading` and `error`, and the new order is read back
264
+ // through `@my-profile` once the write lands.
265
+ export const preferredEmail = requestReducer<null>(
266
+ SET_PREFERRED_EMAIL,
267
+ () => null,
268
+ null,
269
+ );
270
+
271
+ export const oauthClients = requestReducer<OAuthClient[]>(
272
+ LIST_CLIENTS,
273
+ (result) => result?.items ?? [],
274
+ [],
275
+ );
276
+
277
+ // The create and rotate results keep the *whole* client rather than a list
278
+ // entry, because they are the only place the secret ever appears and the
279
+ // panel has to render it from somewhere.
280
+ export const clientCreate = requestReducer<OAuthClient | null>(
281
+ CREATE_CLIENT,
282
+ (result) => result ?? null,
283
+ null,
284
+ );
285
+
286
+ export const clientSecretRotate = requestReducer<OAuthClient | null>(
287
+ ROTATE_CLIENT_SECRET,
288
+ (result) => result ?? null,
289
+ null,
290
+ );
291
+
292
+ export const clientUpdate = requestReducer<OAuthClient | null>(
293
+ UPDATE_CLIENT,
294
+ (result) => result ?? null,
295
+ null,
296
+ );
297
+
298
+ export const clientDelete = requestReducer<boolean>(
299
+ DELETE_CLIENT,
300
+ () => true,
301
+ false,
302
+ );
303
+
304
+ export const oauthGrants = requestReducer<OAuthGrants | null>(
305
+ LIST_GRANTS,
306
+ (result) => result ?? null,
307
+ null,
308
+ );
309
+
310
+ export const grantWithdraw = requestReducer<Record<string, unknown> | null>(
311
+ WITHDRAW_GRANT,
312
+ (result) => result ?? null,
313
+ null,
314
+ );
315
+
316
+ export const consentRequest = requestReducer<ConsentRequest | null>(
317
+ GET_CONSENT_REQUEST,
318
+ (result) => result ?? null,
319
+ null,
320
+ );
321
+
322
+ export const signingKeys = requestReducer<SigningKeyRing | null>(
323
+ LIST_KEYS,
324
+ (result) => result ?? null,
325
+ null,
326
+ );
327
+
328
+ export const keyRotate = requestReducer<SigningKeyRing | null>(
329
+ ROTATE_KEY,
330
+ (result) => result ?? null,
331
+ null,
332
+ );
333
+
334
+ const reducers = {
335
+ loginProviders,
336
+ providerLogin,
337
+ identityCallback,
338
+ magicLinkSend,
339
+ magicLinkConfirm,
340
+ identities,
341
+ linkableProviders,
342
+ groupMembers,
343
+ userAccount,
344
+ identityLinking,
345
+ identityUnlink,
346
+ identityDrivers,
347
+ configuredProviders,
348
+ providerFormSchema,
349
+ clientFormSchema,
350
+ providerCreate,
351
+ providerUpdate,
352
+ providerDelete,
353
+ providerTest,
354
+ myProfile,
355
+ preferredEmail,
356
+ userProfile,
357
+ oauthClients,
358
+ clientCreate,
359
+ clientUpdate,
360
+ clientDelete,
361
+ clientSecretRotate,
362
+ signingKeys,
363
+ keyRotate,
364
+ consentRequest,
365
+ oauthGrants,
366
+ grantWithdraw,
367
+ };
368
+
369
+ export default reducers;