@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,267 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import ClientsPanel from './ClientsPanel';
|
|
6
|
+
import type { OAuthClient, SigningKeyRing } from '../../types';
|
|
7
|
+
|
|
8
|
+
const CLIENT: OAuthClient = {
|
|
9
|
+
'@id': '/@identity-clients/app',
|
|
10
|
+
client_id: 'app',
|
|
11
|
+
title: 'Example App',
|
|
12
|
+
redirect_uris: ['https://app.example.org/cb'],
|
|
13
|
+
grant_types: ['authorization_code'],
|
|
14
|
+
scope: ['openid', 'profile'],
|
|
15
|
+
auth_method: 'client_secret_post',
|
|
16
|
+
public: false,
|
|
17
|
+
enabled: true,
|
|
18
|
+
service_user: '',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const PUBLIC_CLIENT: OAuthClient = {
|
|
22
|
+
...CLIENT,
|
|
23
|
+
'@id': '/@identity-clients/spa',
|
|
24
|
+
client_id: 'spa',
|
|
25
|
+
title: 'Single Page App',
|
|
26
|
+
auth_method: 'none',
|
|
27
|
+
public: true,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const KEYS: SigningKeyRing = {
|
|
31
|
+
'@id': '/@identity-keys',
|
|
32
|
+
algorithm: 'RS256',
|
|
33
|
+
ring_size: 3,
|
|
34
|
+
jwks_uri: 'https://id.example.org/@@oauth-jwks',
|
|
35
|
+
items_total: 2,
|
|
36
|
+
items: [
|
|
37
|
+
{ kid: 'newest-kid', active: true },
|
|
38
|
+
{ kid: 'older-kid', active: false },
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `add` and `edit` views are not rendered here: they are Volto's `Form`,
|
|
44
|
+
* which needs a store and a router this renderer does not provide. What they
|
|
45
|
+
* put on the wire is `helpers/clientSchema`'s own test, and how they look is
|
|
46
|
+
* the stories'.
|
|
47
|
+
*/
|
|
48
|
+
function renderPanel(
|
|
49
|
+
props: Partial<React.ComponentProps<typeof ClientsPanel>> = {},
|
|
50
|
+
) {
|
|
51
|
+
const handlers = {
|
|
52
|
+
onSubmit: vi.fn(),
|
|
53
|
+
onCancel: vi.fn(),
|
|
54
|
+
onEdit: vi.fn(),
|
|
55
|
+
onRotateSecret: vi.fn(),
|
|
56
|
+
onDelete: vi.fn(),
|
|
57
|
+
onRotateKey: vi.fn(),
|
|
58
|
+
onDismissSecret: vi.fn(),
|
|
59
|
+
};
|
|
60
|
+
render(
|
|
61
|
+
<ClientsPanel
|
|
62
|
+
clients={[CLIENT]}
|
|
63
|
+
keys={KEYS}
|
|
64
|
+
loading={false}
|
|
65
|
+
busy={false}
|
|
66
|
+
minted={null}
|
|
67
|
+
view="list"
|
|
68
|
+
editing={null}
|
|
69
|
+
formRef={React.createRef()}
|
|
70
|
+
{...handlers}
|
|
71
|
+
{...props}
|
|
72
|
+
/>,
|
|
73
|
+
);
|
|
74
|
+
return handlers;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Accept the delete confirmation without a real dialog. */
|
|
78
|
+
/**
|
|
79
|
+
* Answer the confirmation dialog.
|
|
80
|
+
*
|
|
81
|
+
* These used to stub `window.confirm`, which is the tell that the panel was
|
|
82
|
+
* asking with a browser dialog: a test had to reach past the component to a
|
|
83
|
+
* global to get at the code behind it. The question is part of the page now,
|
|
84
|
+
* so answering it is a click.
|
|
85
|
+
*
|
|
86
|
+
* @param answer Whether to go through with it.
|
|
87
|
+
*/
|
|
88
|
+
function answerConfirmation(answer: boolean) {
|
|
89
|
+
const action = answer ? 'confirm' : 'cancel';
|
|
90
|
+
// Queried off the document rather than the render container: the dialog is
|
|
91
|
+
// a portal, so it is mounted beside the panel rather than inside it.
|
|
92
|
+
const button = document.querySelector(`[data-action="${action}"]`);
|
|
93
|
+
fireEvent.click(button!);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the confirmation is on screen.
|
|
98
|
+
*
|
|
99
|
+
* @returns Whether the dialog is mounted.
|
|
100
|
+
*/
|
|
101
|
+
function asking(): boolean {
|
|
102
|
+
return Boolean(document.querySelector('[data-action="confirm"]'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('ClientsPanel', () => {
|
|
106
|
+
it('lists the registered clients', () => {
|
|
107
|
+
renderPanel();
|
|
108
|
+
|
|
109
|
+
expect(screen.getByText('Example App')).toBeTruthy();
|
|
110
|
+
expect(screen.getByText('app')).toBeTruthy();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('says nothing is registered when nothing is', () => {
|
|
114
|
+
renderPanel({ clients: [] });
|
|
115
|
+
|
|
116
|
+
expect(screen.getByText(/No clients are registered/)).toBeTruthy();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('shows a loading state', () => {
|
|
120
|
+
renderPanel({ loading: true });
|
|
121
|
+
|
|
122
|
+
expect(screen.getByRole('status').textContent).toContain('Loading clients');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('distinguishes a public client from a confidential one', () => {
|
|
126
|
+
renderPanel({ clients: [CLIENT, PUBLIC_CLIENT] });
|
|
127
|
+
|
|
128
|
+
expect(screen.getByText(/Public \(PKCE required\)/)).toBeTruthy();
|
|
129
|
+
expect(screen.getByText('Confidential')).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('shows what a client may do, not only what it is called', () => {
|
|
133
|
+
renderPanel();
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText('authorization_code')).toBeTruthy();
|
|
136
|
+
// Comma-separated, the way the grants beside them read. The record holds
|
|
137
|
+
// a list now, and rendering it straight would run the scopes together.
|
|
138
|
+
expect(screen.getByText('openid, profile')).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('opens the edit form for a client', () => {
|
|
142
|
+
const { onEdit } = renderPanel();
|
|
143
|
+
|
|
144
|
+
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
|
145
|
+
|
|
146
|
+
expect(onEdit).toHaveBeenCalledWith('app');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('offers no secret rotation for a public client', () => {
|
|
150
|
+
renderPanel({ clients: [PUBLIC_CLIENT] });
|
|
151
|
+
|
|
152
|
+
expect(screen.queryByRole('button', { name: 'Rotate secret' })).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rotates a confidential client secret', () => {
|
|
156
|
+
const { onRotateSecret } = renderPanel();
|
|
157
|
+
|
|
158
|
+
fireEvent.click(screen.getByRole('button', { name: 'Rotate secret' }));
|
|
159
|
+
|
|
160
|
+
expect(onRotateSecret).toHaveBeenCalledWith('app');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('warns that disabling also refuses existing tokens', () => {
|
|
164
|
+
renderPanel({ clients: [{ ...CLIENT, enabled: false }] });
|
|
165
|
+
|
|
166
|
+
expect(screen.getByText(/existing access tokens are refused/)).toBeTruthy();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('asks before unregistering rather than doing it', () => {
|
|
170
|
+
const { onDelete } = renderPanel();
|
|
171
|
+
|
|
172
|
+
fireEvent.click(screen.getByRole('button', { name: 'Unregister' }));
|
|
173
|
+
|
|
174
|
+
expect(asking()).toBe(true);
|
|
175
|
+
expect(onDelete).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('unregisters a client once it is confirmed', () => {
|
|
179
|
+
const { onDelete } = renderPanel();
|
|
180
|
+
|
|
181
|
+
fireEvent.click(screen.getByRole('button', { name: 'Unregister' }));
|
|
182
|
+
answerConfirmation(true);
|
|
183
|
+
|
|
184
|
+
expect(onDelete).toHaveBeenCalledWith('app');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('unregisters nothing when the confirmation is declined', () => {
|
|
188
|
+
const { onDelete } = renderPanel();
|
|
189
|
+
|
|
190
|
+
fireEvent.click(screen.getByRole('button', { name: 'Unregister' }));
|
|
191
|
+
answerConfirmation(false);
|
|
192
|
+
|
|
193
|
+
expect(onDelete).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('shows a freshly minted secret over the listing', () => {
|
|
197
|
+
renderPanel({ minted: { ...CLIENT, secret: 's3cr3t' } });
|
|
198
|
+
|
|
199
|
+
expect(screen.getByRole('alertdialog')).toBeTruthy();
|
|
200
|
+
expect(screen.getByDisplayValue('s3cr3t')).toBeTruthy();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('shows no secret when none was minted', () => {
|
|
204
|
+
renderPanel();
|
|
205
|
+
|
|
206
|
+
expect(screen.queryByRole('alertdialog')).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('disables every action while a request is in flight', () => {
|
|
210
|
+
renderPanel({ busy: true });
|
|
211
|
+
|
|
212
|
+
expect(
|
|
213
|
+
(screen.getByRole('button', { name: 'Unregister' }) as HTMLButtonElement)
|
|
214
|
+
.disabled,
|
|
215
|
+
).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('keeps the key ring out of the listing', () => {
|
|
219
|
+
renderPanel();
|
|
220
|
+
|
|
221
|
+
expect(screen.queryByText('newest-kid')).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('describes the signing ring without any key material', () => {
|
|
225
|
+
renderPanel({ view: 'keys' });
|
|
226
|
+
|
|
227
|
+
expect(screen.getByText('newest-kid')).toBeTruthy();
|
|
228
|
+
expect(screen.getByText(/2 of 3 in the ring/)).toBeTruthy();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('says which key is signing', () => {
|
|
232
|
+
renderPanel({ view: 'keys' });
|
|
233
|
+
|
|
234
|
+
expect(screen.getByText(/— signing/)).toBeTruthy();
|
|
235
|
+
expect(screen.getByText(/verifying only/)).toBeTruthy();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('warns what rotating past the ring bound costs', () => {
|
|
239
|
+
renderPanel({ view: 'keys' });
|
|
240
|
+
|
|
241
|
+
expect(
|
|
242
|
+
screen.getByText(/invalidate tokens\s+still in flight/),
|
|
243
|
+
).toBeTruthy();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('rotates the signing key', () => {
|
|
247
|
+
const { onRotateKey } = renderPanel({ view: 'keys' });
|
|
248
|
+
|
|
249
|
+
fireEvent.click(screen.getByText('Rotate signing key'));
|
|
250
|
+
|
|
251
|
+
expect(onRotateKey).toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('does not render the key section before it has loaded', () => {
|
|
255
|
+
renderPanel({ view: 'keys', keys: null });
|
|
256
|
+
|
|
257
|
+
expect(screen.getByText(/Loading keys/)).toBeTruthy();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('does not offer a rotation while one is in flight', () => {
|
|
261
|
+
renderPanel({ view: 'keys', busy: true });
|
|
262
|
+
|
|
263
|
+
expect(
|
|
264
|
+
(screen.getByText('Rotate signing key') as HTMLButtonElement).disabled,
|
|
265
|
+
).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth client control panel, without store or routing.
|
|
3
|
+
*
|
|
4
|
+
* Shaped after the providers panel, which is itself shaped after Volto's
|
|
5
|
+
* own: a table of what exists, the add and save actions in the toolbar
|
|
6
|
+
* rather than inline, and the form rendered by Volto's `Form` from a schema.
|
|
7
|
+
* Nothing here lays out an input.
|
|
8
|
+
*
|
|
9
|
+
* Which of the three views is on screen is the container's decision, because
|
|
10
|
+
* the toolbar buttons that switch between them live there. This renders the
|
|
11
|
+
* one it is given.
|
|
12
|
+
* @module components/ControlPanel/ClientsPanel
|
|
13
|
+
*/
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|
16
|
+
import { Button, Segment, Table } from 'semantic-ui-react';
|
|
17
|
+
|
|
18
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
19
|
+
import { Form } from '@plone/volto/components/manage/Form';
|
|
20
|
+
import deleteSVG from '@plone/volto/icons/delete.svg';
|
|
21
|
+
import pencilSVG from '@plone/volto/icons/pencil.svg';
|
|
22
|
+
import refreshSVG from '@plone/volto/icons/refresh.svg';
|
|
23
|
+
|
|
24
|
+
import { clientSchema, toFormData } from '../../helpers/clientSchema';
|
|
25
|
+
import type { JsonSchema, OAuthClient, SigningKeyRing } from '../../types';
|
|
26
|
+
import SecretReveal from './SecretReveal';
|
|
27
|
+
|
|
28
|
+
import './ClientsPanel.scss';
|
|
29
|
+
import ConfirmModal from './ConfirmModal';
|
|
30
|
+
|
|
31
|
+
const messages = defineMessages({
|
|
32
|
+
loading: { id: 'Loading clients', defaultMessage: 'Loading clients…' },
|
|
33
|
+
registered: {
|
|
34
|
+
id: 'Registered clients',
|
|
35
|
+
defaultMessage: 'Registered clients',
|
|
36
|
+
},
|
|
37
|
+
type: { id: 'Type', defaultMessage: 'Type' },
|
|
38
|
+
publicClient: {
|
|
39
|
+
id: 'Public (PKCE required)',
|
|
40
|
+
defaultMessage: 'Public (PKCE required)',
|
|
41
|
+
},
|
|
42
|
+
confidential: { id: 'Confidential', defaultMessage: 'Confidential' },
|
|
43
|
+
grants: { id: 'Grants', defaultMessage: 'Grants' },
|
|
44
|
+
scope: { id: 'Scope', defaultMessage: 'Scope' },
|
|
45
|
+
edit: { id: 'Edit', defaultMessage: 'Edit' },
|
|
46
|
+
rotateSecret: { id: 'Rotate secret', defaultMessage: 'Rotate secret' },
|
|
47
|
+
unregister: { id: 'Unregister', defaultMessage: 'Unregister' },
|
|
48
|
+
confirmDelete: {
|
|
49
|
+
id: 'Unregister this client?',
|
|
50
|
+
defaultMessage:
|
|
51
|
+
'Unregister this client? Every token already minted for it stops ' +
|
|
52
|
+
'being accepted at once.',
|
|
53
|
+
},
|
|
54
|
+
noClients: {
|
|
55
|
+
id: 'No clients are registered yet.',
|
|
56
|
+
defaultMessage: 'No clients are registered yet.',
|
|
57
|
+
},
|
|
58
|
+
add: { id: 'Register a client', defaultMessage: 'Register a client' },
|
|
59
|
+
columnTitle: { id: 'Title', defaultMessage: 'Title' },
|
|
60
|
+
columnId: { id: 'Client ID', defaultMessage: 'Client ID' },
|
|
61
|
+
columnEnabled: { id: 'Enabled', defaultMessage: 'Enabled' },
|
|
62
|
+
columnActions: { id: 'Actions', defaultMessage: 'Actions' },
|
|
63
|
+
yes: { id: 'Yes', defaultMessage: 'Yes' },
|
|
64
|
+
no: { id: 'No', defaultMessage: 'No' },
|
|
65
|
+
disabledNotice: {
|
|
66
|
+
id: 'Disabled. Its existing access tokens are refused as well.',
|
|
67
|
+
defaultMessage:
|
|
68
|
+
'Disabled. Its existing access tokens are refused as well: the ' +
|
|
69
|
+
'audience is checked against this registry on every request.',
|
|
70
|
+
},
|
|
71
|
+
signingKeys: { id: 'Signing keys', defaultMessage: 'Signing keys' },
|
|
72
|
+
ring: {
|
|
73
|
+
id: '{total} of {size} in the ring, signing with {algorithm}.',
|
|
74
|
+
defaultMessage:
|
|
75
|
+
'{total} of {size} in the ring, signing with {algorithm}. Public keys ' +
|
|
76
|
+
'are published at {jwks}.',
|
|
77
|
+
},
|
|
78
|
+
signing: { id: 'signing', defaultMessage: 'signing' },
|
|
79
|
+
verifyingOnly: {
|
|
80
|
+
id: 'verifying only',
|
|
81
|
+
defaultMessage: 'verifying only',
|
|
82
|
+
},
|
|
83
|
+
rotateKey: {
|
|
84
|
+
id: 'Rotate signing key',
|
|
85
|
+
defaultMessage: 'Rotate signing key',
|
|
86
|
+
},
|
|
87
|
+
ringWarning: {
|
|
88
|
+
id: 'Older keys stay in the ring so tokens already issued keep verifying.',
|
|
89
|
+
defaultMessage:
|
|
90
|
+
'Older keys stay in the ring so tokens already issued keep verifying. ' +
|
|
91
|
+
'The ring holds {size}; rotating more than that within one ' +
|
|
92
|
+
'access-token lifetime will invalidate tokens still in flight.',
|
|
93
|
+
},
|
|
94
|
+
loadingKeys: { id: 'Loading keys', defaultMessage: 'Loading keys…' },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** Which of the panel's three views is on screen. */
|
|
98
|
+
export type ClientsView = 'list' | 'add' | 'edit' | 'keys';
|
|
99
|
+
|
|
100
|
+
interface ClientsPanelProps {
|
|
101
|
+
clients: OAuthClient[];
|
|
102
|
+
/**
|
|
103
|
+
* The form's schema, as the backend serialized it from `IClientRecords`.
|
|
104
|
+
*
|
|
105
|
+
* Optional so a page talking to a backend that predates it, or one whose
|
|
106
|
+
* request failed, renders an empty form rather than crashing on
|
|
107
|
+
* `schema.fieldsets`.
|
|
108
|
+
*/
|
|
109
|
+
schema?: JsonSchema;
|
|
110
|
+
keys: SigningKeyRing | null;
|
|
111
|
+
loading: boolean;
|
|
112
|
+
busy: boolean;
|
|
113
|
+
/** The client whose secret was just minted, if any. */
|
|
114
|
+
minted: OAuthClient | null;
|
|
115
|
+
view: ClientsView;
|
|
116
|
+
/** The client being edited, when the view is `edit`. */
|
|
117
|
+
editing: string | null;
|
|
118
|
+
/** Handed to `Form`, so the toolbar's Save can submit it. */
|
|
119
|
+
formRef: React.RefObject<any>;
|
|
120
|
+
/** A failed request, rendered by `Form` above the fields. */
|
|
121
|
+
error?: unknown;
|
|
122
|
+
onSubmit: (data: Record<string, unknown>) => void;
|
|
123
|
+
onCancel: () => void;
|
|
124
|
+
onEdit: (clientId: string) => void;
|
|
125
|
+
onRotateSecret: (clientId: string) => void;
|
|
126
|
+
onDelete: (clientId: string) => void;
|
|
127
|
+
onRotateKey: () => void;
|
|
128
|
+
onDismissSecret: () => void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a cell shows when the client has nothing for that column. */
|
|
132
|
+
const NOTHING = '—';
|
|
133
|
+
|
|
134
|
+
const ClientsPanel: React.FC<ClientsPanelProps> = ({
|
|
135
|
+
clients,
|
|
136
|
+
schema,
|
|
137
|
+
keys,
|
|
138
|
+
loading,
|
|
139
|
+
busy,
|
|
140
|
+
minted,
|
|
141
|
+
view,
|
|
142
|
+
editing,
|
|
143
|
+
formRef,
|
|
144
|
+
error,
|
|
145
|
+
onSubmit,
|
|
146
|
+
onCancel,
|
|
147
|
+
onEdit,
|
|
148
|
+
onRotateSecret,
|
|
149
|
+
onDelete,
|
|
150
|
+
onRotateKey,
|
|
151
|
+
onDismissSecret,
|
|
152
|
+
}) => {
|
|
153
|
+
const intl = useIntl();
|
|
154
|
+
const current = editing
|
|
155
|
+
? clients.find((client) => client.client_id === editing)
|
|
156
|
+
: undefined;
|
|
157
|
+
const adding = view === 'add';
|
|
158
|
+
|
|
159
|
+
const [confirming, setConfirming] = React.useState<string | null>(null);
|
|
160
|
+
|
|
161
|
+
const confirmDelete = (clientId: string) => {
|
|
162
|
+
setConfirming(clientId);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const onConfirmDelete = () => {
|
|
166
|
+
const clientId = confirming;
|
|
167
|
+
setConfirming(null);
|
|
168
|
+
if (!clientId) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
onDelete(clientId);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const modal = (
|
|
175
|
+
<ConfirmModal
|
|
176
|
+
open={confirming !== null}
|
|
177
|
+
header={confirming ?? ''}
|
|
178
|
+
content={intl.formatMessage(messages.confirmDelete)}
|
|
179
|
+
onCancel={() => setConfirming(null)}
|
|
180
|
+
onConfirm={onConfirmDelete}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
if (view === 'keys') {
|
|
185
|
+
return (
|
|
186
|
+
<Segment.Group raised className="identity-clients">
|
|
187
|
+
{modal}
|
|
188
|
+
<Segment className="primary">
|
|
189
|
+
{intl.formatMessage(messages.signingKeys)}
|
|
190
|
+
</Segment>
|
|
191
|
+
<Segment className="identity-clients__keys">
|
|
192
|
+
{keys ? (
|
|
193
|
+
<>
|
|
194
|
+
<p>
|
|
195
|
+
<FormattedMessage
|
|
196
|
+
{...messages.ring}
|
|
197
|
+
values={{
|
|
198
|
+
total: keys.items_total,
|
|
199
|
+
size: keys.ring_size,
|
|
200
|
+
algorithm: keys.algorithm,
|
|
201
|
+
jwks: <a href={keys.jwks_uri}>{keys.jwks_uri}</a>,
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
</p>
|
|
205
|
+
<ul>
|
|
206
|
+
{keys.items.map((key) => (
|
|
207
|
+
<li key={key.kid} data-kid={key.kid}>
|
|
208
|
+
<code>{key.kid}</code>
|
|
209
|
+
{key.active ? (
|
|
210
|
+
<strong> — {intl.formatMessage(messages.signing)}</strong>
|
|
211
|
+
) : (
|
|
212
|
+
` — ${intl.formatMessage(messages.verifyingOnly)}`
|
|
213
|
+
)}
|
|
214
|
+
</li>
|
|
215
|
+
))}
|
|
216
|
+
</ul>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className="identity-button"
|
|
220
|
+
disabled={busy}
|
|
221
|
+
onClick={onRotateKey}
|
|
222
|
+
>
|
|
223
|
+
{intl.formatMessage(messages.rotateKey)}
|
|
224
|
+
</button>
|
|
225
|
+
<p className="identity-clients__keys-warning identity-note">
|
|
226
|
+
{intl.formatMessage(messages.ringWarning, {
|
|
227
|
+
size: keys.ring_size,
|
|
228
|
+
})}
|
|
229
|
+
</p>
|
|
230
|
+
</>
|
|
231
|
+
) : (
|
|
232
|
+
<p role="status">{intl.formatMessage(messages.loadingKeys)}</p>
|
|
233
|
+
)}
|
|
234
|
+
</Segment>
|
|
235
|
+
</Segment.Group>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (view === 'add' || view === 'edit') {
|
|
240
|
+
return (
|
|
241
|
+
<Form
|
|
242
|
+
ref={formRef}
|
|
243
|
+
// An add form and an edit form ask for different fields, and `Form`
|
|
244
|
+
// seeds an add form's defaults in its constructor -- once, from the
|
|
245
|
+
// schema it mounted with. Keyed so switching between them remounts
|
|
246
|
+
// rather than carrying the previous form's state into the next.
|
|
247
|
+
key={adding ? 'add' : editing ?? 'edit'}
|
|
248
|
+
title={
|
|
249
|
+
adding
|
|
250
|
+
? intl.formatMessage(messages.add)
|
|
251
|
+
: current?.title || current?.client_id
|
|
252
|
+
}
|
|
253
|
+
schema={clientSchema(schema, adding, intl)}
|
|
254
|
+
formData={toFormData(adding ? undefined : current)}
|
|
255
|
+
requestError={error}
|
|
256
|
+
onSubmit={onSubmit}
|
|
257
|
+
onCancel={onCancel}
|
|
258
|
+
hideActions
|
|
259
|
+
/>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<Segment.Group raised className="identity-clients">
|
|
265
|
+
{modal}
|
|
266
|
+
<Segment className="primary">
|
|
267
|
+
{intl.formatMessage(messages.registered)}
|
|
268
|
+
</Segment>
|
|
269
|
+
<Segment>
|
|
270
|
+
{minted ? (
|
|
271
|
+
<SecretReveal client={minted} onDismiss={onDismissSecret} />
|
|
272
|
+
) : null}
|
|
273
|
+
{loading ? (
|
|
274
|
+
<p role="status">{intl.formatMessage(messages.loading)}</p>
|
|
275
|
+
) : clients.length ? (
|
|
276
|
+
<Table selectable compact>
|
|
277
|
+
<Table.Header>
|
|
278
|
+
<Table.Row>
|
|
279
|
+
<Table.HeaderCell>
|
|
280
|
+
{intl.formatMessage(messages.columnTitle)}
|
|
281
|
+
</Table.HeaderCell>
|
|
282
|
+
<Table.HeaderCell>
|
|
283
|
+
{intl.formatMessage(messages.columnId)}
|
|
284
|
+
</Table.HeaderCell>
|
|
285
|
+
<Table.HeaderCell>
|
|
286
|
+
{intl.formatMessage(messages.type)}
|
|
287
|
+
</Table.HeaderCell>
|
|
288
|
+
<Table.HeaderCell>
|
|
289
|
+
{intl.formatMessage(messages.grants)}
|
|
290
|
+
</Table.HeaderCell>
|
|
291
|
+
<Table.HeaderCell>
|
|
292
|
+
{intl.formatMessage(messages.scope)}
|
|
293
|
+
</Table.HeaderCell>
|
|
294
|
+
<Table.HeaderCell>
|
|
295
|
+
{intl.formatMessage(messages.columnEnabled)}
|
|
296
|
+
</Table.HeaderCell>
|
|
297
|
+
<Table.HeaderCell textAlign="right">
|
|
298
|
+
{intl.formatMessage(messages.columnActions)}
|
|
299
|
+
</Table.HeaderCell>
|
|
300
|
+
</Table.Row>
|
|
301
|
+
</Table.Header>
|
|
302
|
+
<Table.Body>
|
|
303
|
+
{clients.map((client) => (
|
|
304
|
+
<Table.Row key={client['@id']} data-client={client.client_id}>
|
|
305
|
+
<Table.Cell>
|
|
306
|
+
{client.title || client.client_id}
|
|
307
|
+
{client.enabled ? null : (
|
|
308
|
+
<p
|
|
309
|
+
className="identity-clients__disabled identity-note"
|
|
310
|
+
role="status"
|
|
311
|
+
>
|
|
312
|
+
{intl.formatMessage(messages.disabledNotice)}
|
|
313
|
+
</p>
|
|
314
|
+
)}
|
|
315
|
+
</Table.Cell>
|
|
316
|
+
<Table.Cell>
|
|
317
|
+
<code>{client.client_id}</code>
|
|
318
|
+
</Table.Cell>
|
|
319
|
+
<Table.Cell>
|
|
320
|
+
{intl.formatMessage(
|
|
321
|
+
client.public
|
|
322
|
+
? messages.publicClient
|
|
323
|
+
: messages.confidential,
|
|
324
|
+
)}
|
|
325
|
+
</Table.Cell>
|
|
326
|
+
<Table.Cell>
|
|
327
|
+
{client.grant_types.join(', ') || NOTHING}
|
|
328
|
+
</Table.Cell>
|
|
329
|
+
<Table.Cell>{client.scope.join(', ') || NOTHING}</Table.Cell>
|
|
330
|
+
<Table.Cell>
|
|
331
|
+
{intl.formatMessage(
|
|
332
|
+
client.enabled ? messages.yes : messages.no,
|
|
333
|
+
)}
|
|
334
|
+
</Table.Cell>
|
|
335
|
+
<Table.Cell textAlign="right">
|
|
336
|
+
<Button
|
|
337
|
+
basic
|
|
338
|
+
icon
|
|
339
|
+
disabled={busy}
|
|
340
|
+
aria-label={intl.formatMessage(messages.edit)}
|
|
341
|
+
title={intl.formatMessage(messages.edit)}
|
|
342
|
+
onClick={() => onEdit(client.client_id)}
|
|
343
|
+
>
|
|
344
|
+
<Icon name={pencilSVG} size="20px" />
|
|
345
|
+
</Button>
|
|
346
|
+
{/* A public client has no secret to rotate: PKCE is
|
|
347
|
+
what stands in for one. */}
|
|
348
|
+
{client.public ? null : (
|
|
349
|
+
<Button
|
|
350
|
+
basic
|
|
351
|
+
icon
|
|
352
|
+
disabled={busy}
|
|
353
|
+
aria-label={intl.formatMessage(messages.rotateSecret)}
|
|
354
|
+
title={intl.formatMessage(messages.rotateSecret)}
|
|
355
|
+
onClick={() => onRotateSecret(client.client_id)}
|
|
356
|
+
>
|
|
357
|
+
<Icon name={refreshSVG} size="20px" />
|
|
358
|
+
</Button>
|
|
359
|
+
)}
|
|
360
|
+
<Button
|
|
361
|
+
basic
|
|
362
|
+
icon
|
|
363
|
+
data-action="delete"
|
|
364
|
+
disabled={busy}
|
|
365
|
+
aria-label={intl.formatMessage(messages.unregister)}
|
|
366
|
+
title={intl.formatMessage(messages.unregister)}
|
|
367
|
+
onClick={() => confirmDelete(client.client_id)}
|
|
368
|
+
>
|
|
369
|
+
<Icon name={deleteSVG} size="20px" />
|
|
370
|
+
</Button>
|
|
371
|
+
</Table.Cell>
|
|
372
|
+
</Table.Row>
|
|
373
|
+
))}
|
|
374
|
+
</Table.Body>
|
|
375
|
+
</Table>
|
|
376
|
+
) : (
|
|
377
|
+
<p className="identity-clients__empty identity-note">
|
|
378
|
+
{intl.formatMessage(messages.noClients)}
|
|
379
|
+
</p>
|
|
380
|
+
)}
|
|
381
|
+
</Segment>
|
|
382
|
+
</Segment.Group>
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
export default ClientsPanel;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import ConfirmModal from './ConfirmModal';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ConfirmModal> = {
|
|
6
|
+
title: 'Identity/ControlPanel/ConfirmModal',
|
|
7
|
+
component: ConfirmModal,
|
|
8
|
+
args: {
|
|
9
|
+
open: true,
|
|
10
|
+
header: 'GitHub',
|
|
11
|
+
content:
|
|
12
|
+
'Deleting this provider removes its configuration. The identities ' +
|
|
13
|
+
'linked through it are account data and are kept.',
|
|
14
|
+
onCancel: () => {},
|
|
15
|
+
onConfirm: () => {},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof ConfirmModal>;
|
|
21
|
+
|
|
22
|
+
/** Deleting a provider, which is what this replaced a browser dialog for. */
|
|
23
|
+
export const Delete: Story = {};
|
|
24
|
+
|
|
25
|
+
/** A different verb, for an action that is destructive but is not a delete. */
|
|
26
|
+
export const Withdraw: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
header: 'Example App',
|
|
29
|
+
content: 'Example App will no longer have access to your account.',
|
|
30
|
+
confirmLabel: 'Withdraw access',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Not being asked. */
|
|
35
|
+
export const Closed: Story = { args: { open: false } };
|