@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.
- package/.changelog.draft +261 -0
- package/.release-it.json +34 -0
- package/CHANGELOG.md +269 -0
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/babel.config.js +17 -0
- package/locales/de/LC_MESSAGES/volto.po +1162 -0
- package/locales/en/LC_MESSAGES/volto.po +1162 -0
- package/locales/es/LC_MESSAGES/volto.po +1169 -0
- package/locales/pt_BR/LC_MESSAGES/volto.po +1167 -0
- package/locales/volto.pot +1164 -0
- package/news/.changelog_template.jinja +15 -0
- package/news/.gitkeep +0 -0
- package/package.json +66 -0
- package/public/.gitkeep +0 -0
- package/src/actions/actions.test.ts +226 -0
- package/src/actions/index.ts +530 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/Applications/Applications.tsx +169 -0
- package/src/components/Applications/ApplicationsPanel.scss +59 -0
- package/src/components/Applications/ApplicationsPanel.stories.tsx +82 -0
- package/src/components/Applications/ApplicationsPanel.test.tsx +235 -0
- package/src/components/Applications/ApplicationsPanel.tsx +342 -0
- package/src/components/Callback/Callback.scss +17 -0
- package/src/components/Callback/Callback.stories.tsx +43 -0
- package/src/components/Callback/Callback.test.tsx +262 -0
- package/src/components/Callback/Callback.tsx +170 -0
- package/src/components/Consent/Consent.test.tsx +75 -0
- package/src/components/Consent/Consent.tsx +98 -0
- package/src/components/Consent/ConsentPanel.scss +37 -0
- package/src/components/Consent/ConsentPanel.stories.tsx +80 -0
- package/src/components/Consent/ConsentPanel.test.tsx +138 -0
- package/src/components/Consent/ConsentPanel.tsx +162 -0
- package/src/components/ControlPanel/ClientsControlPanel.stories.tsx +38 -0
- package/src/components/ControlPanel/ClientsControlPanel.tsx +321 -0
- package/src/components/ControlPanel/ClientsPanel.scss +21 -0
- package/src/components/ControlPanel/ClientsPanel.stories.tsx +71 -0
- package/src/components/ControlPanel/ClientsPanel.test.tsx +267 -0
- package/src/components/ControlPanel/ClientsPanel.tsx +386 -0
- package/src/components/ControlPanel/ConfirmModal.stories.tsx +35 -0
- package/src/components/ControlPanel/ConfirmModal.test.tsx +66 -0
- package/src/components/ControlPanel/ConfirmModal.tsx +68 -0
- package/src/components/ControlPanel/ProviderForm.stories.tsx +107 -0
- package/src/components/ControlPanel/ProvidersControlPanel.scss +26 -0
- package/src/components/ControlPanel/ProvidersControlPanel.stories.tsx +88 -0
- package/src/components/ControlPanel/ProvidersControlPanel.tsx +600 -0
- package/src/components/ControlPanel/RenderUsers.stories.tsx +112 -0
- package/src/components/ControlPanel/RenderUsers.test.tsx +145 -0
- package/src/components/ControlPanel/RenderUsers.tsx +263 -0
- package/src/components/ControlPanel/SecretReveal.scss +32 -0
- package/src/components/ControlPanel/SecretReveal.stories.tsx +33 -0
- package/src/components/ControlPanel/SecretReveal.test.tsx +101 -0
- package/src/components/ControlPanel/SecretReveal.tsx +105 -0
- package/src/components/ControlPanel/UserAccount.stories.tsx +69 -0
- package/src/components/ControlPanel/UserAccount.test.tsx +117 -0
- package/src/components/ControlPanel/UserAccount.tsx +125 -0
- package/src/components/ControlPanel/UserAccountPanel.scss +38 -0
- package/src/components/ControlPanel/UserAccountPanel.stories.tsx +77 -0
- package/src/components/ControlPanel/UserAccountPanel.test.tsx +173 -0
- package/src/components/ControlPanel/UserAccountPanel.tsx +273 -0
- package/src/components/FirstLogin/FirstLogin.scss +12 -0
- package/src/components/FirstLogin/FirstLogin.stories.tsx +50 -0
- package/src/components/FirstLogin/FirstLogin.tsx +94 -0
- package/src/components/Identities/Identities.stories.tsx +51 -0
- package/src/components/Identities/Identities.test.tsx +134 -0
- package/src/components/Identities/Identities.tsx +217 -0
- package/src/components/Identities/IdentitiesList.scss +49 -0
- package/src/components/Identities/IdentitiesList.stories.tsx +71 -0
- package/src/components/Identities/IdentitiesList.test.tsx +218 -0
- package/src/components/Identities/IdentitiesList.tsx +207 -0
- package/src/components/Identities/ProfileEmails.scss +62 -0
- package/src/components/Identities/ProfileEmails.stories.tsx +75 -0
- package/src/components/Identities/ProfileEmails.test.tsx +132 -0
- package/src/components/Identities/ProfileEmails.tsx +217 -0
- package/src/components/Login/Login.stories.tsx +63 -0
- package/src/components/Login/Login.test.tsx +189 -0
- package/src/components/Login/Login.tsx +163 -0
- package/src/components/Login/LoginForm.scss +39 -0
- package/src/components/Login/LoginForm.stories.tsx +112 -0
- package/src/components/Login/LoginForm.test.tsx +483 -0
- package/src/components/Login/LoginForm.tsx +321 -0
- package/src/components/Login/LoginOverlay.scss +44 -0
- package/src/components/Login/LoginOverlay.stories.tsx +73 -0
- package/src/components/Login/LoginOverlay.test.tsx +98 -0
- package/src/components/Login/LoginOverlay.tsx +107 -0
- package/src/components/Login/LoginPanel.scss +50 -0
- package/src/components/Login/LoginPanel.stories.tsx +40 -0
- package/src/components/Login/LoginPanel.tsx +60 -0
- package/src/components/Login/MagicLinkForm.scss +25 -0
- package/src/components/Login/MagicLinkForm.stories.tsx +27 -0
- package/src/components/Login/MagicLinkForm.tsx +133 -0
- package/src/components/Login/PasswordForm.scss +82 -0
- package/src/components/Login/PasswordForm.stories.tsx +26 -0
- package/src/components/Login/PasswordForm.tsx +159 -0
- package/src/components/Login/ProviderButton.scss +103 -0
- package/src/components/Login/ProviderButton.stories.tsx +74 -0
- package/src/components/Login/ProviderButton.test.tsx +99 -0
- package/src/components/Login/ProviderButton.tsx +97 -0
- package/src/components/ProfileGate/ProfileGate.stories.tsx +118 -0
- package/src/components/ProfileGate/ProfileGate.test.tsx +370 -0
- package/src/components/ProfileGate/ProfileGate.tsx +172 -0
- package/src/components/Toolbar/PersonalTools.stories.tsx +111 -0
- package/src/components/Toolbar/PersonalTools.test.tsx +165 -0
- package/src/components/Toolbar/PersonalTools.tsx +177 -0
- package/src/components/Toolbar/Toolbar.jsx +766 -0
- package/src/components/UserMenu/ApplicationsMenuItem.stories.tsx +62 -0
- package/src/components/UserMenu/ApplicationsMenuItem.test.tsx +109 -0
- package/src/components/UserMenu/ApplicationsMenuItem.tsx +82 -0
- package/src/components/UserMenu/IdentitiesMenuItem.stories.tsx +35 -0
- package/src/components/UserMenu/IdentitiesMenuItem.test.tsx +103 -0
- package/src/components/UserMenu/IdentitiesMenuItem.tsx +34 -0
- package/src/components/UserMenu/MenuItem.stories.tsx +32 -0
- package/src/components/UserMenu/MenuItem.tsx +48 -0
- package/src/components/UserMenu/ProfileMenuItem.stories.tsx +76 -0
- package/src/components/UserMenu/ProfileMenuItem.test.tsx +90 -0
- package/src/components/UserMenu/ProfileMenuItem.tsx +60 -0
- package/src/components/UserMenu/UserAvatar.scss +23 -0
- package/src/components/UserMenu/UserAvatar.stories.tsx +121 -0
- package/src/components/UserMenu/UserAvatar.test.tsx +120 -0
- package/src/components/UserMenu/UserAvatar.tsx +119 -0
- package/src/components/UserMenu/UserMenuPlugs.stories.tsx +101 -0
- package/src/components/UserMenu/UserMenuPlugs.test.tsx +202 -0
- package/src/components/UserMenu/UserMenuPlugs.tsx +142 -0
- package/src/components/UserMenu/UserProfileLoader.test.tsx +211 -0
- package/src/components/UserMenu/UserProfileLoader.tsx +54 -0
- package/src/components/Views/GroupView.scss +36 -0
- package/src/components/Views/GroupView.stories.tsx +98 -0
- package/src/components/Views/GroupView.test.tsx +137 -0
- package/src/components/Views/GroupView.tsx +178 -0
- package/src/components/Views/ProfileView.scss +27 -0
- package/src/components/Views/ProfileView.stories.tsx +34 -0
- package/src/components/Views/ProfileView.test.tsx +82 -0
- package/src/components/Views/ProfileView.tsx +100 -0
- package/src/components/Widgets/ProviderIconWidget.scss +21 -0
- package/src/components/Widgets/ProviderIconWidget.stories.tsx +30 -0
- package/src/components/Widgets/ProviderIconWidget.test.tsx +55 -0
- package/src/components/Widgets/ProviderIconWidget.tsx +143 -0
- package/src/config/menu.ts +35 -0
- package/src/config/reducers.ts +7 -0
- package/src/config/routes.test.ts +177 -0
- package/src/config/routes.ts +143 -0
- package/src/config/settings.test.ts +34 -0
- package/src/config/settings.ts +72 -0
- package/src/config/views.ts +30 -0
- package/src/config/widgets.ts +25 -0
- package/src/constants/ActionTypes.ts +42 -0
- package/src/constants/vocabularies.ts +20 -0
- package/src/customizations/volto/components/manage/Controlpanels/Users/RenderUsers.tsx +23 -0
- package/src/customizations/volto/components/manage/Toolbar/PersonalTools.tsx +21 -0
- package/src/customizations/volto/components/manage/Toolbar/Toolbar.jsx +21 -0
- package/src/helpers/avatar.test.ts +79 -0
- package/src/helpers/avatar.ts +85 -0
- package/src/helpers/callback.test.ts +60 -0
- package/src/helpers/callback.ts +71 -0
- package/src/helpers/clientSchema.test.ts +182 -0
- package/src/helpers/clientSchema.ts +181 -0
- package/src/helpers/firstLogin.test.ts +90 -0
- package/src/helpers/firstLogin.ts +76 -0
- package/src/helpers/groupmap.test.ts +81 -0
- package/src/helpers/groupmap.ts +50 -0
- package/src/helpers/identities.ts +22 -0
- package/src/helpers/navigate.test.ts +132 -0
- package/src/helpers/navigate.ts +126 -0
- package/src/helpers/profileGate.test.ts +150 -0
- package/src/helpers/profileGate.ts +191 -0
- package/src/helpers/profileSource.ts +41 -0
- package/src/helpers/propertymap.test.ts +71 -0
- package/src/helpers/propertymap.ts +43 -0
- package/src/helpers/providerSchema.test.ts +410 -0
- package/src/helpers/providerSchema.ts +362 -0
- package/src/helpers/returnUrl.test.ts +42 -0
- package/src/helpers/returnUrl.ts +41 -0
- package/src/helpers/rowmap.ts +110 -0
- package/src/helpers/showPloneLogin.test.ts +78 -0
- package/src/helpers/showPloneLogin.ts +55 -0
- package/src/helpers/token.test.ts +62 -0
- package/src/helpers/token.ts +71 -0
- package/src/index.ts +22 -0
- package/src/reducers/index.ts +369 -0
- package/src/reducers/reducers.test.ts +369 -0
- package/src/stories/fixtures.tsx +640 -0
- package/src/storybook/withPage.test.tsx +90 -0
- package/src/storybook/withPage.tsx +110 -0
- package/src/storybook/withUserMenu.test.tsx +78 -0
- package/src/storybook/withUserMenu.tsx +136 -0
- package/src/styles.css +262 -0
- package/src/testing.tsx +86 -0
- package/src/types.ts +441 -0
- package/towncrier.toml +38 -0
- package/tsconfig.json +22 -0
- 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;
|