@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent screen, without any store or routing in it.
|
|
3
|
+
*
|
|
4
|
+
* A relying party sent this browser here to be asked something personal, and
|
|
5
|
+
* the whole value of asking it *here* rather than on a standalone page is
|
|
6
|
+
* that it looks like the site the person believes they are signing in to. So
|
|
7
|
+
* this is ordinary site markup, in the same panel the login page uses.
|
|
8
|
+
*
|
|
9
|
+
* Two things it says that the protocol does not require, and both are the
|
|
10
|
+
* reason the screen exists:
|
|
11
|
+
*
|
|
12
|
+
* - **who would be agreeing.** The browser may hold a session the user forgot
|
|
13
|
+
* about, and agreeing on behalf of the wrong account is the mistake this
|
|
14
|
+
* screen is there to make visible.
|
|
15
|
+
*
|
|
16
|
+
* - **what each scope actually releases.** "profile" means nothing to the
|
|
17
|
+
* person being asked; "your name, username, website, picture and bio" is
|
|
18
|
+
* the real question. The backend sends the claim list, and a scope it does
|
|
19
|
+
* not recognise says so rather than being quietly dropped.
|
|
20
|
+
* @module components/Consent/ConsentPanel
|
|
21
|
+
*/
|
|
22
|
+
import React from 'react';
|
|
23
|
+
import { Container } from '@plone/components';
|
|
24
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
25
|
+
|
|
26
|
+
import type { ConsentRequest } from '../../types';
|
|
27
|
+
|
|
28
|
+
import './ConsentPanel.scss';
|
|
29
|
+
|
|
30
|
+
const messages = defineMessages({
|
|
31
|
+
loading: {
|
|
32
|
+
id: 'Loading the request',
|
|
33
|
+
defaultMessage: 'Loading the request…',
|
|
34
|
+
},
|
|
35
|
+
heading: {
|
|
36
|
+
id: 'Allow {client} to use your account?',
|
|
37
|
+
defaultMessage: 'Allow {client} to use your account?',
|
|
38
|
+
},
|
|
39
|
+
signedInAs: {
|
|
40
|
+
id: 'You are signed in as {user}.',
|
|
41
|
+
defaultMessage: 'You are signed in as {user}.',
|
|
42
|
+
},
|
|
43
|
+
willBeAbleTo: {
|
|
44
|
+
id: 'It will be able to read:',
|
|
45
|
+
defaultMessage: 'It will be able to read:',
|
|
46
|
+
},
|
|
47
|
+
nothingInParticular: {
|
|
48
|
+
id: 'nothing beyond the fact that you signed in',
|
|
49
|
+
defaultMessage: 'nothing beyond the fact that you signed in',
|
|
50
|
+
},
|
|
51
|
+
allow: { id: 'Allow', defaultMessage: 'Allow' },
|
|
52
|
+
deny: { id: 'Deny', defaultMessage: 'Deny' },
|
|
53
|
+
unavailable: {
|
|
54
|
+
id: 'This authorization request cannot be shown.',
|
|
55
|
+
defaultMessage:
|
|
56
|
+
'This authorization request cannot be shown. It may have been sent by ' +
|
|
57
|
+
'an application this site does not know, or it may have expired — ' +
|
|
58
|
+
'start again from the application you were signing in to.',
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
interface ConsentPanelProps {
|
|
63
|
+
/** The request being asked about, once it has loaded. */
|
|
64
|
+
request: ConsentRequest | null;
|
|
65
|
+
loading: boolean;
|
|
66
|
+
/** Whether describing the request failed. */
|
|
67
|
+
error?: unknown;
|
|
68
|
+
/** Whether an answer is on its way and the browser is leaving. */
|
|
69
|
+
answering: boolean;
|
|
70
|
+
onAnswer: (allow: boolean) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ConsentPanel: React.FC<ConsentPanelProps> = ({
|
|
74
|
+
request,
|
|
75
|
+
loading,
|
|
76
|
+
error,
|
|
77
|
+
answering,
|
|
78
|
+
onAnswer,
|
|
79
|
+
}) => {
|
|
80
|
+
const intl = useIntl();
|
|
81
|
+
|
|
82
|
+
if (loading || (!request && !error)) {
|
|
83
|
+
return (
|
|
84
|
+
<Container className="identity-consent" role="status">
|
|
85
|
+
{intl.formatMessage(messages.loading)}
|
|
86
|
+
</Container>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!request) {
|
|
91
|
+
// No buttons. There is nothing to agree to, and an "Allow" here would be
|
|
92
|
+
// agreeing to something this page could not describe.
|
|
93
|
+
return (
|
|
94
|
+
<Container className="identity-consent">
|
|
95
|
+
<p className="identity-error" role="alert">
|
|
96
|
+
{intl.formatMessage(messages.unavailable)}
|
|
97
|
+
</p>
|
|
98
|
+
</Container>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Every claim the request would release, deduplicated across scopes: a
|
|
103
|
+
// reader wants the list of things, not the list of scopes that happen to
|
|
104
|
+
// contain them.
|
|
105
|
+
const claims = Array.from(
|
|
106
|
+
new Set(request.scopes.flatMap((scope) => scope.claims)),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Container className="identity-consent">
|
|
111
|
+
<h1>
|
|
112
|
+
{intl.formatMessage(messages.heading, {
|
|
113
|
+
client: request.client.title,
|
|
114
|
+
})}
|
|
115
|
+
</h1>
|
|
116
|
+
<p className="identity-consent__who identity-note">
|
|
117
|
+
{intl.formatMessage(messages.signedInAs, {
|
|
118
|
+
user: request.user.label,
|
|
119
|
+
})}
|
|
120
|
+
</p>
|
|
121
|
+
|
|
122
|
+
<p>{intl.formatMessage(messages.willBeAbleTo)}</p>
|
|
123
|
+
{claims.length ? (
|
|
124
|
+
<ul className="identity-consent__claims">
|
|
125
|
+
{claims.map((claim) => (
|
|
126
|
+
<li key={claim}>
|
|
127
|
+
<code>{claim}</code>
|
|
128
|
+
</li>
|
|
129
|
+
))}
|
|
130
|
+
</ul>
|
|
131
|
+
) : (
|
|
132
|
+
<p className="identity-note">
|
|
133
|
+
{intl.formatMessage(messages.nothingInParticular)}
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<div className="identity-consent__actions">
|
|
138
|
+
{/* Deny first in the DOM would put it first for a keyboard, and
|
|
139
|
+
first is where a hurried Enter lands. Allow is the affirmative
|
|
140
|
+
answer, so it is the one that has to be chosen. */}
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
className="identity-button identity-button--primary"
|
|
144
|
+
disabled={answering}
|
|
145
|
+
onClick={() => onAnswer(true)}
|
|
146
|
+
>
|
|
147
|
+
{intl.formatMessage(messages.allow)}
|
|
148
|
+
</button>
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
className="identity-button"
|
|
152
|
+
disabled={answering}
|
|
153
|
+
onClick={() => onAnswer(false)}
|
|
154
|
+
>
|
|
155
|
+
{intl.formatMessage(messages.deny)}
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
</Container>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export default ConsentPanel;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import ClientsControlPanel from './ClientsControlPanel';
|
|
4
|
+
import {
|
|
5
|
+
CLIENTS,
|
|
6
|
+
KEYRING,
|
|
7
|
+
LOADED,
|
|
8
|
+
LOADING,
|
|
9
|
+
withStore,
|
|
10
|
+
} from '../../stories/fixtures';
|
|
11
|
+
|
|
12
|
+
const meta: Meta<typeof ClientsControlPanel> = {
|
|
13
|
+
title: 'Identity/ControlPanel/ClientsControlPanel',
|
|
14
|
+
component: ClientsControlPanel,
|
|
15
|
+
};
|
|
16
|
+
export default meta;
|
|
17
|
+
|
|
18
|
+
type Story = StoryObj<typeof ClientsControlPanel>;
|
|
19
|
+
|
|
20
|
+
const base = {
|
|
21
|
+
oauthClients: { ...LOADED, data: CLIENTS },
|
|
22
|
+
signingKeys: { ...LOADED, data: KEYRING },
|
|
23
|
+
clientCreate: {},
|
|
24
|
+
clientUpdate: {},
|
|
25
|
+
clientDelete: {},
|
|
26
|
+
clientSecretRotate: {},
|
|
27
|
+
keyRotate: {},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const Default: Story = { decorators: [withStore(base)] };
|
|
31
|
+
|
|
32
|
+
export const Loading: Story = {
|
|
33
|
+
decorators: [withStore({ ...base, oauthClients: { ...LOADING, data: [] } })],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const RotatingAKey: Story = {
|
|
37
|
+
decorators: [withStore({ ...base, keyRotate: LOADING })],
|
|
38
|
+
};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-panel container: store, dispatch, and page chrome around the
|
|
3
|
+
* clients panel.
|
|
4
|
+
*
|
|
5
|
+
* The chrome is the providers panel's chrome, which is Volto's own: a
|
|
6
|
+
* centred container, and a toolbar carrying the actions that switch what the
|
|
7
|
+
* page shows -- the signing keys, a registration form, and the way back to
|
|
8
|
+
* the control-panel listing. Which view is on is decided here rather than in
|
|
9
|
+
* the panel, because those buttons live here.
|
|
10
|
+
* @module components/ControlPanel/ClientsControlPanel
|
|
11
|
+
*/
|
|
12
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
14
|
+
import { createPortal } from 'react-dom';
|
|
15
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
16
|
+
import { Button, Container } from 'semantic-ui-react';
|
|
17
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
18
|
+
import { toast } from 'react-toastify';
|
|
19
|
+
|
|
20
|
+
import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
|
|
21
|
+
import { useClient } from '@plone/volto/hooks/client/useClient';
|
|
22
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
23
|
+
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
|
|
24
|
+
import Toast from '@plone/volto/components/manage/Toast/Toast';
|
|
25
|
+
import addSVG from '@plone/volto/icons/add.svg';
|
|
26
|
+
import backSVG from '@plone/volto/icons/back.svg';
|
|
27
|
+
import clearSVG from '@plone/volto/icons/clear.svg';
|
|
28
|
+
import keySVG from '@plone/volto/icons/key.svg';
|
|
29
|
+
import saveSVG from '@plone/volto/icons/save.svg';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
createClient,
|
|
33
|
+
deleteClient,
|
|
34
|
+
listClients,
|
|
35
|
+
listKeys,
|
|
36
|
+
rotateClientSecret,
|
|
37
|
+
rotateKey,
|
|
38
|
+
updateClient,
|
|
39
|
+
} from '../../actions';
|
|
40
|
+
import { fromFormData } from '../../helpers/clientSchema';
|
|
41
|
+
import ClientsPanel from './ClientsPanel';
|
|
42
|
+
import type { ClientsView } from './ClientsPanel';
|
|
43
|
+
import type { OAuthClient } from '../../types';
|
|
44
|
+
|
|
45
|
+
const messages = defineMessages({
|
|
46
|
+
// The configlet's own title, so the page, the browser tab and the entry in
|
|
47
|
+
// the control-panel listing all say the same thing.
|
|
48
|
+
title: { id: 'OAuth clients', defaultMessage: 'OAuth clients' },
|
|
49
|
+
back: { id: 'Back', defaultMessage: 'Back' },
|
|
50
|
+
add: { id: 'Register a client', defaultMessage: 'Register a client' },
|
|
51
|
+
keys: { id: 'Signing keys', defaultMessage: 'Signing keys' },
|
|
52
|
+
save: { id: 'Save', defaultMessage: 'Save' },
|
|
53
|
+
cancel: { id: 'Cancel', defaultMessage: 'Cancel' },
|
|
54
|
+
saved: { id: 'Changes saved', defaultMessage: 'Changes saved' },
|
|
55
|
+
deleted: { id: 'Client unregistered', defaultMessage: 'Client unregistered' },
|
|
56
|
+
rotated: { id: 'Signing key rotated', defaultMessage: 'Signing key rotated' },
|
|
57
|
+
error: { id: 'Error', defaultMessage: 'Error' },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const ClientsControlPanel: React.FC = () => {
|
|
61
|
+
const intl = useIntl();
|
|
62
|
+
const isClient = useClient();
|
|
63
|
+
const { pathname } = useLocation();
|
|
64
|
+
const dispatch = useDispatch();
|
|
65
|
+
const formRef = useRef<any>(null);
|
|
66
|
+
|
|
67
|
+
const [view, setView] = useState<ClientsView>('list');
|
|
68
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
69
|
+
const [error, setError] = useState<unknown>(null);
|
|
70
|
+
// Held here rather than read from the store on every render: the secret
|
|
71
|
+
// must survive the re-listing that follows a create, and it must disappear
|
|
72
|
+
// when the operator says they have saved it rather than when some other
|
|
73
|
+
// request happens to resolve.
|
|
74
|
+
const [minted, setMinted] = useState<OAuthClient | null>(null);
|
|
75
|
+
|
|
76
|
+
const clients = useSelector((state: any) => state.oauthClients);
|
|
77
|
+
const keys = useSelector((state: any) => state.signingKeys);
|
|
78
|
+
// Served beside the listing, from the interface the client's own storage is
|
|
79
|
+
// described by, so the form and the records cannot say different things.
|
|
80
|
+
const clientFormSchema = useSelector(
|
|
81
|
+
(state: any) => state.clientFormSchema?.data,
|
|
82
|
+
);
|
|
83
|
+
const created = useSelector((state: any) => state.clientCreate);
|
|
84
|
+
const rotated = useSelector((state: any) => state.clientSecretRotate);
|
|
85
|
+
const updated = useSelector((state: any) => state.clientUpdate);
|
|
86
|
+
const removed = useSelector((state: any) => state.clientDelete);
|
|
87
|
+
const keyRotated = useSelector((state: any) => state.keyRotate);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
dispatch(listClients());
|
|
91
|
+
dispatch(listKeys());
|
|
92
|
+
}, [dispatch]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const answer = created?.loaded ? created.data : null;
|
|
96
|
+
if (answer?.secret) {
|
|
97
|
+
setMinted(answer);
|
|
98
|
+
}
|
|
99
|
+
}, [created?.loaded, created?.data]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const answer = rotated?.loaded ? rotated.data : null;
|
|
103
|
+
if (answer?.secret) {
|
|
104
|
+
setMinted(answer);
|
|
105
|
+
}
|
|
106
|
+
}, [rotated?.loaded, rotated?.data]);
|
|
107
|
+
|
|
108
|
+
const fail = useCallback(
|
|
109
|
+
(err: any) => {
|
|
110
|
+
setError(err);
|
|
111
|
+
toast.error(
|
|
112
|
+
<Toast
|
|
113
|
+
error
|
|
114
|
+
title={intl.formatMessage(messages.error)}
|
|
115
|
+
content={err?.response?.body?.error?.message ?? String(err)}
|
|
116
|
+
/>,
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
[intl],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const closeForm = useCallback(() => {
|
|
123
|
+
setView('list');
|
|
124
|
+
setEditing(null);
|
|
125
|
+
setError(null);
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
const succeed = useCallback(
|
|
129
|
+
(message: string) => {
|
|
130
|
+
toast.success(<Toast success title={message} />);
|
|
131
|
+
closeForm();
|
|
132
|
+
// Re-read rather than patch the local copy: the listing never carries
|
|
133
|
+
// the secret, and a locally patched entry would.
|
|
134
|
+
dispatch(listClients());
|
|
135
|
+
},
|
|
136
|
+
[closeForm, dispatch],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const onSubmit = useCallback(
|
|
140
|
+
(data: Record<string, unknown>) => {
|
|
141
|
+
const adding = view === 'add';
|
|
142
|
+
const payload = fromFormData(data, adding);
|
|
143
|
+
const action = adding
|
|
144
|
+
? createClient(payload)
|
|
145
|
+
: updateClient(editing as string, payload);
|
|
146
|
+
(dispatch(action) as any)
|
|
147
|
+
.then(() => succeed(intl.formatMessage(messages.saved)))
|
|
148
|
+
.catch(fail);
|
|
149
|
+
},
|
|
150
|
+
[dispatch, editing, fail, intl, succeed, view],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const onEdit = useCallback((clientId: string) => {
|
|
154
|
+
setEditing(clientId);
|
|
155
|
+
setError(null);
|
|
156
|
+
setView('edit');
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
const onRotateSecret = useCallback(
|
|
160
|
+
(clientId: string) => {
|
|
161
|
+
// No toast: the secret itself is what appears, and a success message
|
|
162
|
+
// over it would be one more thing between the operator and the only
|
|
163
|
+
// copy they will ever see.
|
|
164
|
+
(dispatch(rotateClientSecret(clientId)) as any).catch(fail);
|
|
165
|
+
},
|
|
166
|
+
[dispatch, fail],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const onDelete = useCallback(
|
|
170
|
+
(clientId: string) => {
|
|
171
|
+
(dispatch(deleteClient(clientId)) as any)
|
|
172
|
+
.then(() => succeed(intl.formatMessage(messages.deleted)))
|
|
173
|
+
.catch(fail);
|
|
174
|
+
},
|
|
175
|
+
[dispatch, fail, intl, succeed],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const onRotateKey = useCallback(() => {
|
|
179
|
+
(dispatch(rotateKey()) as any)
|
|
180
|
+
.then(() => {
|
|
181
|
+
toast.success(
|
|
182
|
+
<Toast success title={intl.formatMessage(messages.rotated)} />,
|
|
183
|
+
);
|
|
184
|
+
dispatch(listKeys());
|
|
185
|
+
})
|
|
186
|
+
.catch(fail);
|
|
187
|
+
}, [dispatch, fail, intl]);
|
|
188
|
+
|
|
189
|
+
const onDismissSecret = useCallback(() => setMinted(null), []);
|
|
190
|
+
|
|
191
|
+
const isForm = view === 'add' || view === 'edit';
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div id="page-controlpanel" className="identity-controlpanel">
|
|
195
|
+
<Helmet title={intl.formatMessage(messages.title)} />
|
|
196
|
+
<Container>
|
|
197
|
+
<ClientsPanel
|
|
198
|
+
schema={clientFormSchema}
|
|
199
|
+
clients={clients?.data ?? []}
|
|
200
|
+
keys={keys?.data ?? null}
|
|
201
|
+
loading={Boolean(clients?.loading)}
|
|
202
|
+
busy={Boolean(
|
|
203
|
+
created?.loading ||
|
|
204
|
+
updated?.loading ||
|
|
205
|
+
removed?.loading ||
|
|
206
|
+
rotated?.loading ||
|
|
207
|
+
keyRotated?.loading,
|
|
208
|
+
)}
|
|
209
|
+
minted={minted}
|
|
210
|
+
view={view}
|
|
211
|
+
editing={editing}
|
|
212
|
+
formRef={formRef}
|
|
213
|
+
error={error}
|
|
214
|
+
onSubmit={onSubmit}
|
|
215
|
+
onCancel={closeForm}
|
|
216
|
+
onEdit={onEdit}
|
|
217
|
+
onRotateSecret={onRotateSecret}
|
|
218
|
+
onDelete={onDelete}
|
|
219
|
+
onRotateKey={onRotateKey}
|
|
220
|
+
onDismissSecret={onDismissSecret}
|
|
221
|
+
/>
|
|
222
|
+
</Container>
|
|
223
|
+
{isClient &&
|
|
224
|
+
createPortal(
|
|
225
|
+
<Toolbar
|
|
226
|
+
pathname={pathname}
|
|
227
|
+
hideDefaultViewButtons
|
|
228
|
+
inner={
|
|
229
|
+
isForm ? (
|
|
230
|
+
<>
|
|
231
|
+
<Button
|
|
232
|
+
id="toolbar-save"
|
|
233
|
+
className="save"
|
|
234
|
+
aria-label={intl.formatMessage(messages.save)}
|
|
235
|
+
onClick={() => formRef.current?.onSubmit()}
|
|
236
|
+
>
|
|
237
|
+
<Icon
|
|
238
|
+
name={saveSVG}
|
|
239
|
+
className="circled"
|
|
240
|
+
size="30px"
|
|
241
|
+
title={intl.formatMessage(messages.save)}
|
|
242
|
+
/>
|
|
243
|
+
</Button>
|
|
244
|
+
<Button
|
|
245
|
+
className="cancel"
|
|
246
|
+
aria-label={intl.formatMessage(messages.cancel)}
|
|
247
|
+
onClick={closeForm}
|
|
248
|
+
>
|
|
249
|
+
<Icon
|
|
250
|
+
name={clearSVG}
|
|
251
|
+
className="circled"
|
|
252
|
+
size="30px"
|
|
253
|
+
title={intl.formatMessage(messages.cancel)}
|
|
254
|
+
/>
|
|
255
|
+
</Button>
|
|
256
|
+
</>
|
|
257
|
+
) : view === 'keys' ? (
|
|
258
|
+
<Button
|
|
259
|
+
id="toolbar-back-to-clients"
|
|
260
|
+
aria-label={intl.formatMessage(messages.title)}
|
|
261
|
+
onClick={() => setView('list')}
|
|
262
|
+
>
|
|
263
|
+
<Icon
|
|
264
|
+
name={backSVG}
|
|
265
|
+
className="circled"
|
|
266
|
+
size="30px"
|
|
267
|
+
title={intl.formatMessage(messages.title)}
|
|
268
|
+
/>
|
|
269
|
+
</Button>
|
|
270
|
+
) : (
|
|
271
|
+
<>
|
|
272
|
+
<Button
|
|
273
|
+
id="toolbar-keys"
|
|
274
|
+
aria-label={intl.formatMessage(messages.keys)}
|
|
275
|
+
onClick={() => setView('keys')}
|
|
276
|
+
>
|
|
277
|
+
<Icon
|
|
278
|
+
name={keySVG}
|
|
279
|
+
className="circled"
|
|
280
|
+
size="30px"
|
|
281
|
+
title={intl.formatMessage(messages.keys)}
|
|
282
|
+
/>
|
|
283
|
+
</Button>
|
|
284
|
+
<Button
|
|
285
|
+
id="toolbar-add"
|
|
286
|
+
aria-label={intl.formatMessage(messages.add)}
|
|
287
|
+
onClick={() => {
|
|
288
|
+
setEditing(null);
|
|
289
|
+
setError(null);
|
|
290
|
+
setView('add');
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
<Icon
|
|
294
|
+
name={addSVG}
|
|
295
|
+
className="circled"
|
|
296
|
+
size="30px"
|
|
297
|
+
title={intl.formatMessage(messages.add)}
|
|
298
|
+
/>
|
|
299
|
+
</Button>
|
|
300
|
+
{/* A router link, not an anchor: an `href` here left the
|
|
301
|
+
toolbar's back button reloading the whole application
|
|
302
|
+
to reach a route Volto already has. */}
|
|
303
|
+
<Link className="item" to="/controlpanel">
|
|
304
|
+
<Icon
|
|
305
|
+
name={backSVG}
|
|
306
|
+
className="circled"
|
|
307
|
+
size="30px"
|
|
308
|
+
title={intl.formatMessage(messages.back)}
|
|
309
|
+
/>
|
|
310
|
+
</Link>
|
|
311
|
+
</>
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
/>,
|
|
315
|
+
document.getElementById('toolbar') as HTMLElement,
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export default ClientsControlPanel;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth client control panel.
|
|
3
|
+
*
|
|
4
|
+
* Almost nothing, for the same reason the providers panel is almost nothing:
|
|
5
|
+
* the table, the form and the toolbar are Volto's own, and styling them here
|
|
6
|
+
* is how a control panel stops looking like the others. What is left is the
|
|
7
|
+
* key ring, which is a list rather than a table.
|
|
8
|
+
*/
|
|
9
|
+
.identity-clients__keys {
|
|
10
|
+
ul {
|
|
11
|
+
padding: 0;
|
|
12
|
+
margin: 0 0 var(--identity-gap);
|
|
13
|
+
list-style: none;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.identity-clients__disabled,
|
|
18
|
+
.identity-clients__keys-warning {
|
|
19
|
+
margin-top: 0.5rem;
|
|
20
|
+
font-size: 0.875rem;
|
|
21
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import ClientsPanel from './ClientsPanel';
|
|
5
|
+
import {
|
|
6
|
+
CLIENTS,
|
|
7
|
+
KEYRING,
|
|
8
|
+
MINTED_CLIENT,
|
|
9
|
+
withStore,
|
|
10
|
+
} from '../../stories/fixtures';
|
|
11
|
+
|
|
12
|
+
const meta: Meta<typeof ClientsPanel> = {
|
|
13
|
+
title: 'Identity/ControlPanel/ClientsPanel',
|
|
14
|
+
component: ClientsPanel,
|
|
15
|
+
args: {
|
|
16
|
+
clients: CLIENTS,
|
|
17
|
+
keys: KEYRING,
|
|
18
|
+
loading: false,
|
|
19
|
+
busy: false,
|
|
20
|
+
minted: null,
|
|
21
|
+
view: 'list',
|
|
22
|
+
editing: null,
|
|
23
|
+
formRef: React.createRef(),
|
|
24
|
+
onSubmit: () => {},
|
|
25
|
+
onCancel: () => {},
|
|
26
|
+
onEdit: () => {},
|
|
27
|
+
onRotateSecret: () => {},
|
|
28
|
+
onDelete: () => {},
|
|
29
|
+
onRotateKey: () => {},
|
|
30
|
+
onDismissSecret: () => {},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
export default meta;
|
|
34
|
+
|
|
35
|
+
type Story = StoryObj<typeof ClientsPanel>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Volto's `Form` is a connected component, so the two form views need a
|
|
39
|
+
* store above them even though this schema asks nothing of it.
|
|
40
|
+
*/
|
|
41
|
+
const withForm = withStore({});
|
|
42
|
+
|
|
43
|
+
/** Who may log in *to* this site. The add action lives in the toolbar. */
|
|
44
|
+
export const Registered: Story = {};
|
|
45
|
+
|
|
46
|
+
export const Empty: Story = { args: { clients: [] } };
|
|
47
|
+
|
|
48
|
+
export const Loading: Story = { args: { loading: true, clients: [] } };
|
|
49
|
+
|
|
50
|
+
export const Busy: Story = { args: { busy: true } };
|
|
51
|
+
|
|
52
|
+
/** Straight after registering one: the secret is readable exactly now. */
|
|
53
|
+
export const SecretJustMinted: Story = { args: { minted: MINTED_CLIENT } };
|
|
54
|
+
|
|
55
|
+
/** The registration form, reached from the toolbar's add button. */
|
|
56
|
+
export const Registering: Story = {
|
|
57
|
+
args: { view: 'add' },
|
|
58
|
+
decorators: [withForm],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The same form over a stored client, minus what cannot be changed. */
|
|
62
|
+
export const Editing: Story = {
|
|
63
|
+
args: { view: 'edit', editing: CLIENTS[0].client_id },
|
|
64
|
+
decorators: [withForm],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The ring its tokens are signed with, behind its own toolbar button. */
|
|
68
|
+
export const Keys: Story = { args: { view: 'keys' } };
|
|
69
|
+
|
|
70
|
+
/** A site whose server layer is installed but has never signed anything. */
|
|
71
|
+
export const NoKeysYet: Story = { args: { view: 'keys', keys: null } };
|