@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,30 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import ProviderIconWidget from './ProviderIconWidget';
|
|
4
|
+
|
|
5
|
+
const SVG =
|
|
6
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">' +
|
|
7
|
+
'<path d="M8 1l7 13H1z"/></svg>';
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof ProviderIconWidget> = {
|
|
10
|
+
title: 'Identity/Widgets/ProviderIconWidget',
|
|
11
|
+
component: ProviderIconWidget,
|
|
12
|
+
args: {
|
|
13
|
+
id: 'icon',
|
|
14
|
+
title: 'Icon',
|
|
15
|
+
value: `filenameb64:${btoa('icon.svg')};datab64:${btoa(SVG)}`,
|
|
16
|
+
onChange: () => {},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
type Story = StoryObj<typeof ProviderIconWidget>;
|
|
22
|
+
|
|
23
|
+
/** An icon already uploaded, previewed as the button will draw it. */
|
|
24
|
+
export const WithIcon: Story = {};
|
|
25
|
+
|
|
26
|
+
/** No icon yet, which is how every provider starts. */
|
|
27
|
+
export const Empty: Story = { args: { value: '' } };
|
|
28
|
+
|
|
29
|
+
/** While something else is in flight. */
|
|
30
|
+
export const Disabled: Story = { args: { isDisabled: true } };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '../../testing';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import ProviderIconWidget, { iconSource } from './ProviderIconWidget';
|
|
6
|
+
|
|
7
|
+
const SVG = '<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>';
|
|
8
|
+
const ENVELOPE = `filenameb64:${btoa('icon.svg')};datab64:${btoa(SVG)}`;
|
|
9
|
+
|
|
10
|
+
describe('iconSource', () => {
|
|
11
|
+
it('reads the document out of the envelope', () => {
|
|
12
|
+
expect(iconSource(ENVELOPE)).toBe(SVG);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('is empty for a value that is not an envelope', () => {
|
|
16
|
+
expect(iconSource('')).toBe('');
|
|
17
|
+
expect(iconSource(undefined)).toBe('');
|
|
18
|
+
expect(iconSource('<svg/>')).toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('is empty rather than throwing on an undecodable payload', () => {
|
|
22
|
+
// A field that cannot be decoded shows empty and is uploaded again. A
|
|
23
|
+
// crash here would take the whole provider form with it.
|
|
24
|
+
expect(iconSource('filenameb64:aaa;datab64:!!!not base64!!!')).toBe('');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('ProviderIconWidget', () => {
|
|
29
|
+
it('previews by inlining the document', () => {
|
|
30
|
+
// Not through `/@@site-logo/<filename>`, which is what Volto's own
|
|
31
|
+
// registry image widget does and which 404s for a provider icon.
|
|
32
|
+
render(
|
|
33
|
+
<ProviderIconWidget id="icon" value={ENVELOPE} onChange={vi.fn()} />,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
screen.getByTestId('provider-icon-preview').querySelector('svg'),
|
|
38
|
+
).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('offers a replacement once there is an icon', () => {
|
|
42
|
+
render(
|
|
43
|
+
<ProviderIconWidget id="icon" value={ENVELOPE} onChange={vi.fn()} />,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(screen.getByRole('button', { name: /replace/i })).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('offers only an upload when there is none', () => {
|
|
50
|
+
render(<ProviderIconWidget id="icon" value="" onChange={vi.fn()} />);
|
|
51
|
+
|
|
52
|
+
expect(screen.getByRole('button', { name: /choose/i })).toBeTruthy();
|
|
53
|
+
expect(screen.queryByTestId('provider-icon-preview')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload the SVG a login button is drawn with.
|
|
3
|
+
*
|
|
4
|
+
* Volto ships `RegistryImageWidget` for exactly this shape of value — the
|
|
5
|
+
* `filenameb64:<name>;datab64:<bytes>` envelope Plone stores `site_logo` in —
|
|
6
|
+
* and this widget sends and reads the identical envelope, so the backend
|
|
7
|
+
* field is an ordinary `schema.Bytes`.
|
|
8
|
+
*
|
|
9
|
+
* What it does not reuse is the preview. `RegistryImageWidget` builds one from
|
|
10
|
+
* `/@@site-logo/<filename>`, which is Plone's own view and answers 404 for a
|
|
11
|
+
* provider icon. This previews by decoding the envelope and inlining the
|
|
12
|
+
* document, which costs no request and is exactly what the login button does
|
|
13
|
+
* with the same bytes — so what an operator sees here is what a visitor gets.
|
|
14
|
+
*
|
|
15
|
+
* Refusing a non-SVG file is the backend's job and is done on the field
|
|
16
|
+
* constraint, so an upload that slips past the accept filter is still refused
|
|
17
|
+
* with a message naming what was wrong. The filter here is a courtesy, not a
|
|
18
|
+
* check.
|
|
19
|
+
* @module components/Widgets/ProviderIconWidget
|
|
20
|
+
*/
|
|
21
|
+
import React from 'react';
|
|
22
|
+
import { Button } from 'semantic-ui-react';
|
|
23
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
24
|
+
|
|
25
|
+
import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
|
|
26
|
+
|
|
27
|
+
import './ProviderIconWidget.scss';
|
|
28
|
+
|
|
29
|
+
const messages = defineMessages({
|
|
30
|
+
choose: { id: 'Choose a file', defaultMessage: 'Choose a file' },
|
|
31
|
+
replace: {
|
|
32
|
+
id: 'Replace existing file',
|
|
33
|
+
defaultMessage: 'Replace existing file',
|
|
34
|
+
},
|
|
35
|
+
remove: { id: 'Remove', defaultMessage: 'Remove' },
|
|
36
|
+
hint: {
|
|
37
|
+
id: 'provider-icon-hint',
|
|
38
|
+
defaultMessage:
|
|
39
|
+
'An SVG file. It is drawn in the button, so it takes the button' +
|
|
40
|
+
' colours; anything else is refused.',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** What the envelope looks like, and how its two halves are named. */
|
|
45
|
+
const ENVELOPE = /^filenameb64:([^;]*);datab64:(.*)$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the SVG source out of a stored value.
|
|
49
|
+
*
|
|
50
|
+
* @param value The field value, either an envelope or empty.
|
|
51
|
+
* @returns The source, or an empty string when there is nothing to draw.
|
|
52
|
+
*/
|
|
53
|
+
export function iconSource(value: unknown): string {
|
|
54
|
+
const match = ENVELOPE.exec(String(value ?? ''));
|
|
55
|
+
if (!match) {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
// `atob` gives one byte per character, which is not the same as UTF-8
|
|
60
|
+
// text: an icon carrying a non-ASCII character in a title would come back
|
|
61
|
+
// mojibake without this step.
|
|
62
|
+
const bytes = Uint8Array.from(atob(match[2]), (c) => c.charCodeAt(0));
|
|
63
|
+
return new TextDecoder().decode(bytes);
|
|
64
|
+
} catch {
|
|
65
|
+
// A value that is not decodable is not a crash: the field shows empty and
|
|
66
|
+
// the operator uploads again.
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ProviderIconWidgetProps {
|
|
72
|
+
id: string;
|
|
73
|
+
value?: string;
|
|
74
|
+
onChange: (id: string, value: string) => void;
|
|
75
|
+
isDisabled?: boolean;
|
|
76
|
+
[key: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ProviderIconWidget: React.FC<ProviderIconWidgetProps> = (props) => {
|
|
80
|
+
const { id, value, onChange, isDisabled } = props;
|
|
81
|
+
const intl = useIntl();
|
|
82
|
+
const input = React.useRef<HTMLInputElement>(null);
|
|
83
|
+
const source = iconSource(value);
|
|
84
|
+
|
|
85
|
+
const onFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
86
|
+
const file = event.target.files?.[0];
|
|
87
|
+
if (!file) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const reader = new FileReader();
|
|
91
|
+
reader.onload = () => {
|
|
92
|
+
const parsed = /^data:(.*);(.*),(.*)$/.exec(String(reader.result ?? ''));
|
|
93
|
+
if (!parsed) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
onChange(id, `filenameb64:${btoa(file.name)};datab64:${parsed[3]}`);
|
|
97
|
+
};
|
|
98
|
+
reader.readAsDataURL(file);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<FormFieldWrapper {...props} className="provider-icon-widget">
|
|
103
|
+
{source ? (
|
|
104
|
+
<div
|
|
105
|
+
className="provider-icon-widget__preview"
|
|
106
|
+
data-testid="provider-icon-preview"
|
|
107
|
+
// The same document the login button inlines. It reached here from
|
|
108
|
+
// the backend, which sanitizes an icon on assignment rather than on
|
|
109
|
+
// render, so what is stored is already what is safe to draw.
|
|
110
|
+
dangerouslySetInnerHTML={{ __html: source }}
|
|
111
|
+
/>
|
|
112
|
+
) : null}
|
|
113
|
+
|
|
114
|
+
<input
|
|
115
|
+
ref={input}
|
|
116
|
+
type="file"
|
|
117
|
+
accept="image/svg+xml,.svg"
|
|
118
|
+
hidden
|
|
119
|
+
onChange={onFile}
|
|
120
|
+
/>
|
|
121
|
+
<Button
|
|
122
|
+
type="button"
|
|
123
|
+
disabled={isDisabled}
|
|
124
|
+
onClick={() => input.current?.click()}
|
|
125
|
+
>
|
|
126
|
+
{intl.formatMessage(source ? messages.replace : messages.choose)}
|
|
127
|
+
</Button>
|
|
128
|
+
{source ? (
|
|
129
|
+
<Button
|
|
130
|
+
type="button"
|
|
131
|
+
basic
|
|
132
|
+
disabled={isDisabled}
|
|
133
|
+
onClick={() => onChange(id, '')}
|
|
134
|
+
>
|
|
135
|
+
{intl.formatMessage(messages.remove)}
|
|
136
|
+
</Button>
|
|
137
|
+
) : null}
|
|
138
|
+
<p className="help">{intl.formatMessage(messages.hint)}</p>
|
|
139
|
+
</FormFieldWrapper>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export default ProviderIconWidget;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ConfigType } from '@plone/registry';
|
|
2
|
+
import ApplicationsMenuItem from '../components/UserMenu/ApplicationsMenuItem';
|
|
3
|
+
import IdentitiesMenuItem from '../components/UserMenu/IdentitiesMenuItem';
|
|
4
|
+
import ProfileMenuItem from '../components/UserMenu/ProfileMenuItem';
|
|
5
|
+
import UserProfileLoader from '../components/UserMenu/UserProfileLoader';
|
|
6
|
+
import {
|
|
7
|
+
PersonalInformationMenuItem,
|
|
8
|
+
PreferencesMenuItem,
|
|
9
|
+
SiteSetupMenuItem,
|
|
10
|
+
} from '../components/UserMenu/UserMenuPlugs';
|
|
11
|
+
|
|
12
|
+
export default function install(config: ConfigType) {
|
|
13
|
+
// A `Plug` renders nothing itself -- it registers with the pluggable and
|
|
14
|
+
// returns null -- so it has to be mounted somewhere that is always rendered.
|
|
15
|
+
// `appExtras` with an empty `match` is that place: Volto hands it to
|
|
16
|
+
// `matchPath`, and an empty path matches every route. The entry only becomes
|
|
17
|
+
// visible when Volto renders the user menu, which is authenticated-only.
|
|
18
|
+
config.settings.appExtras = [
|
|
19
|
+
...(config.settings.appExtras ?? []),
|
|
20
|
+
// Renders nothing; it is here to keep the signed-in user in the store
|
|
21
|
+
// for everything below, which Volto only loads when the menu opens.
|
|
22
|
+
{ match: '', component: UserProfileLoader, props: {} },
|
|
23
|
+
// Volto's own three entries, which this package's shadow of
|
|
24
|
+
// `PersonalTools` no longer writes into the list itself. Registered here
|
|
25
|
+
// beside the two of our own so the whole menu is described in one place,
|
|
26
|
+
// and so a site can drop any of them by filtering this list.
|
|
27
|
+
{ match: '', component: PersonalInformationMenuItem, props: {} },
|
|
28
|
+
{ match: '', component: PreferencesMenuItem, props: {} },
|
|
29
|
+
{ match: '', component: SiteSetupMenuItem, props: {} },
|
|
30
|
+
{ match: '', component: IdentitiesMenuItem, props: {} },
|
|
31
|
+
{ match: '', component: ApplicationsMenuItem, props: {} },
|
|
32
|
+
{ match: '', component: ProfileMenuItem, props: {} },
|
|
33
|
+
];
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import install, {
|
|
4
|
+
CALLBACK_PATH,
|
|
5
|
+
CLIENTS_CONTROLPANEL_PATH,
|
|
6
|
+
CONSENT_PATH,
|
|
7
|
+
CONTROLPANEL_PATH,
|
|
8
|
+
FALLBACK_LOGIN_PATH,
|
|
9
|
+
USER_ACCOUNT_PATH,
|
|
10
|
+
userAccountUrl,
|
|
11
|
+
} from './routes';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A config as Volto hands one to an add-on's install step.
|
|
15
|
+
*
|
|
16
|
+
* @returns The config object, with the keys this step appends to.
|
|
17
|
+
*/
|
|
18
|
+
function emptyConfig(): any {
|
|
19
|
+
return { settings: { nonContentRoutes: [] }, addonRoutes: [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('the routes install step', () => {
|
|
23
|
+
it('registers a route per page this add-on serves', () => {
|
|
24
|
+
const config = emptyConfig();
|
|
25
|
+
|
|
26
|
+
install(config);
|
|
27
|
+
|
|
28
|
+
const paths = config.addonRoutes.map((route: any) => route.path);
|
|
29
|
+
expect(paths).toContain(CALLBACK_PATH);
|
|
30
|
+
expect(paths).toContain(CONSENT_PATH);
|
|
31
|
+
expect(paths).toContain(CONTROLPANEL_PATH);
|
|
32
|
+
expect(paths).toContain(USER_ACCOUNT_PATH);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('the user account route', () => {
|
|
36
|
+
it('is a page rather than an overlay', () => {
|
|
37
|
+
// What it shows is a page's worth of read-only facts about one person.
|
|
38
|
+
// As a modal it could not be linked to, bookmarked, opened in a new
|
|
39
|
+
// tab or reached with the back button.
|
|
40
|
+
const config = emptyConfig();
|
|
41
|
+
|
|
42
|
+
install(config);
|
|
43
|
+
|
|
44
|
+
const route = config.addonRoutes.find(
|
|
45
|
+
(each: any) => each.path === USER_ACCOUNT_PATH,
|
|
46
|
+
);
|
|
47
|
+
expect(route).toBeTruthy();
|
|
48
|
+
expect(route.exact).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('needs no nonContentRoutes entry of its own', () => {
|
|
52
|
+
// Volto's own `/\/controlpanel\/.*$/` already covers everything below
|
|
53
|
+
// there, which is why this route lives under the users control panel.
|
|
54
|
+
expect(USER_ACCOUNT_PATH.startsWith('/controlpanel/')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('escapes a userid that a path segment could not carry', () => {
|
|
58
|
+
expect(userAccountUrl('a b/c')).toBe(
|
|
59
|
+
'/controlpanel/users/a%20b%2Fc/account',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('builds a URL the route matches', () => {
|
|
64
|
+
// The constant and the builder are two spellings of one path, and
|
|
65
|
+
// nothing else would catch them drifting apart.
|
|
66
|
+
const pattern = new RegExp(
|
|
67
|
+
`^${USER_ACCOUNT_PATH.replace(':userid', '[^/]+')}$`,
|
|
68
|
+
);
|
|
69
|
+
expect(pattern.test(userAccountUrl('erico'))).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('keeps its pages out of the content routes', () => {
|
|
74
|
+
// Volto would otherwise ask the backend for a content object at each of
|
|
75
|
+
// these paths, and render a 404 while the page itself works.
|
|
76
|
+
const config = emptyConfig();
|
|
77
|
+
|
|
78
|
+
install(config);
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
config.settings.nonContentRoutes.some((pattern: RegExp) =>
|
|
82
|
+
pattern.test(CONSENT_PATH),
|
|
83
|
+
),
|
|
84
|
+
).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('gives both control panels an icon', () => {
|
|
88
|
+
// Keyed by configlet id, which is what the backend's controlpanel.xml
|
|
89
|
+
// calls them. Without it Volto draws its generic placeholder, and these
|
|
90
|
+
// two are the only unlabelled tiles in the listing.
|
|
91
|
+
const config = emptyConfig();
|
|
92
|
+
|
|
93
|
+
install(config);
|
|
94
|
+
|
|
95
|
+
expect(
|
|
96
|
+
config.settings.controlPanelsIcons['identity-providers'],
|
|
97
|
+
).toBeTruthy();
|
|
98
|
+
expect(config.settings.controlPanelsIcons['identity-clients']).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keys the icons the way the panel routes are named', () => {
|
|
102
|
+
// The configlet id is the last segment of the route, and a mismatch is
|
|
103
|
+
// silent: the panel renders and the tile stays blank.
|
|
104
|
+
const config = emptyConfig();
|
|
105
|
+
|
|
106
|
+
install(config);
|
|
107
|
+
|
|
108
|
+
for (const id of Object.keys(config.settings.controlPanelsIcons)) {
|
|
109
|
+
expect(
|
|
110
|
+
[CONTROLPANEL_PATH, CLIENTS_CONTROLPANEL_PATH].some((path) =>
|
|
111
|
+
path.endsWith(`/${id}`),
|
|
112
|
+
),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("leaves Volto's own login form reachable", () => {
|
|
118
|
+
// This add-on takes over /login, so a page it cannot render is a site
|
|
119
|
+
// nobody can sign in to -- including the administrator who would fix it.
|
|
120
|
+
const config = emptyConfig();
|
|
121
|
+
|
|
122
|
+
install(config);
|
|
123
|
+
|
|
124
|
+
const fallback = config.addonRoutes.find(
|
|
125
|
+
(route: any) => route.path === FALLBACK_LOGIN_PATH,
|
|
126
|
+
);
|
|
127
|
+
expect(fallback).toBeDefined();
|
|
128
|
+
expect(fallback.component).toBeTruthy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not point the fallback at this add-on', () => {
|
|
132
|
+
// The whole value of the route is that it renders none of this package's
|
|
133
|
+
// components, so pointing it at the add-on's own Login would be worse
|
|
134
|
+
// than not having it: it would look like an escape and be a loop.
|
|
135
|
+
const config = emptyConfig();
|
|
136
|
+
|
|
137
|
+
install(config);
|
|
138
|
+
|
|
139
|
+
const routes = Object.fromEntries(
|
|
140
|
+
config.addonRoutes.map((route: any) => [route.path, route.component]),
|
|
141
|
+
);
|
|
142
|
+
// Asserted present as well as different: a missing route is also "not
|
|
143
|
+
// the same component", and this would otherwise pass by being absent.
|
|
144
|
+
expect(routes[FALLBACK_LOGIN_PATH]).toBeTruthy();
|
|
145
|
+
expect(routes[FALLBACK_LOGIN_PATH]).not.toBe(routes['/login']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps the fallback out of the content routes', () => {
|
|
149
|
+
// Volto's own `/login` entry does not cover it: nonContentRoutes strings
|
|
150
|
+
// are tested as unanchored regexes, and `/login` does not occur in
|
|
151
|
+
// `/fallback_login`.
|
|
152
|
+
const config = emptyConfig();
|
|
153
|
+
|
|
154
|
+
install(config);
|
|
155
|
+
|
|
156
|
+
expect(
|
|
157
|
+
config.settings.nonContentRoutes.some((pattern: RegExp) =>
|
|
158
|
+
pattern.test(FALLBACK_LOGIN_PATH),
|
|
159
|
+
),
|
|
160
|
+
).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('keeps whatever another add-on already registered', () => {
|
|
164
|
+
const config = {
|
|
165
|
+
settings: {
|
|
166
|
+
nonContentRoutes: [/^\/other$/],
|
|
167
|
+
controlPanelsIcons: { other: 'svg' },
|
|
168
|
+
},
|
|
169
|
+
addonRoutes: [{ path: '/other' }],
|
|
170
|
+
} as any;
|
|
171
|
+
|
|
172
|
+
install(config);
|
|
173
|
+
|
|
174
|
+
expect(config.addonRoutes[0].path).toBe('/other');
|
|
175
|
+
expect(config.settings.controlPanelsIcons.other).toBe('svg');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ConfigType } from '@plone/registry';
|
|
2
|
+
import appsSVG from '@plone/volto/icons/apps.svg';
|
|
3
|
+
import worldSVG from '@plone/volto/icons/world.svg';
|
|
4
|
+
import Applications from '../components/Applications/Applications';
|
|
5
|
+
import Callback from '../components/Callback/Callback';
|
|
6
|
+
import Consent from '../components/Consent/Consent';
|
|
7
|
+
import FirstLogin from '../components/FirstLogin/FirstLogin';
|
|
8
|
+
import ClientsControlPanel from '../components/ControlPanel/ClientsControlPanel';
|
|
9
|
+
import ProvidersControlPanel from '../components/ControlPanel/ProvidersControlPanel';
|
|
10
|
+
import UserAccount from '../components/ControlPanel/UserAccount';
|
|
11
|
+
import Identities from '../components/Identities/Identities';
|
|
12
|
+
import Login from '../components/Login/Login';
|
|
13
|
+
import VoltoLogin from '@plone/volto/components/theme/Login/Login';
|
|
14
|
+
|
|
15
|
+
/** The frontend route the provider redirects back to. */
|
|
16
|
+
export const CALLBACK_PATH = '/login-identity';
|
|
17
|
+
|
|
18
|
+
/** Where a just-signed-in user is routed based on their Profile state. */
|
|
19
|
+
export const FIRST_LOGIN_PATH = '/first-login';
|
|
20
|
+
|
|
21
|
+
/** Where a signed-in user manages their own sign-in methods. */
|
|
22
|
+
export const IDENTITIES_PATH = '/identities';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where a signed-in user sees the applications they have authorized.
|
|
26
|
+
*
|
|
27
|
+
* The other direction from `IDENTITIES_PATH`: that page is the providers
|
|
28
|
+
* they sign in *with*, this one the applications they signed in *to*. Only
|
|
29
|
+
* a site running the `[server]` layer has any, and only that layer publishes
|
|
30
|
+
* the endpoint behind it.
|
|
31
|
+
*/
|
|
32
|
+
export const APPLICATIONS_PATH = '/applications';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where a user is asked whether they agree to an authorization request.
|
|
36
|
+
*
|
|
37
|
+
* Only reached on a site running the `[server]` layer, and only when that
|
|
38
|
+
* layer's `server_consent_url` names it: the authorization endpoint renders
|
|
39
|
+
* a standalone page of its own otherwise. The route exists regardless, since
|
|
40
|
+
* a frontend cannot be configured as the consent screen before it has one.
|
|
41
|
+
*/
|
|
42
|
+
export const CONSENT_PATH = '/oauth-consent';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where Volto's own login form stays reachable.
|
|
46
|
+
*
|
|
47
|
+
* This add-on takes over `/login` entirely, and its Login already carries a
|
|
48
|
+
* password form beside the provider list -- so this is not the way to sign in
|
|
49
|
+
* with a password. It is the way in when that page cannot be rendered at all:
|
|
50
|
+
* a provider list that fails to load, a misconfigured add-on, a JavaScript
|
|
51
|
+
* error in a component this package owns. Every one of those locks out the
|
|
52
|
+
* administrator who would go and fix it, and none of them are reachable
|
|
53
|
+
* through a page this package draws.
|
|
54
|
+
*
|
|
55
|
+
* The name is `volto-authomatic`'s, which ships the same escape at
|
|
56
|
+
* `/fallback_login` and `/failsafe_login`. A site migrating from it keeps the
|
|
57
|
+
* URL its operators already know.
|
|
58
|
+
*/
|
|
59
|
+
export const FALLBACK_LOGIN_PATH = '/fallback_login';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where a Manager reads one user's account.
|
|
63
|
+
*
|
|
64
|
+
* Under `/controlpanel/users`, because that is the listing it is reached
|
|
65
|
+
* from and the page is a control-panel page: Volto's own
|
|
66
|
+
* `/\/controlpanel\/.*$/` already keeps everything below there out of the
|
|
67
|
+
* content routes, so this needs no entry of its own.
|
|
68
|
+
*
|
|
69
|
+
* A route rather than the modal it used to be. What it shows is a page's
|
|
70
|
+
* worth of read-only facts about one person, it is not a form with an
|
|
71
|
+
* outcome, and as an overlay it could not be linked to, bookmarked, opened
|
|
72
|
+
* in a new tab or reached with the back button.
|
|
73
|
+
*/
|
|
74
|
+
export const USER_ACCOUNT_PATH = '/controlpanel/users/:userid/account';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* That route for one user.
|
|
78
|
+
*
|
|
79
|
+
* @param userid The account to show.
|
|
80
|
+
* @returns The path, with the userid escaped -- a userid is whatever the
|
|
81
|
+
* provider minted and may carry characters a path segment may not.
|
|
82
|
+
*/
|
|
83
|
+
export function userAccountUrl(userid: string): string {
|
|
84
|
+
return `/controlpanel/users/${encodeURIComponent(userid)}/account`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Where a Manager configures providers. */
|
|
88
|
+
export const CONTROLPANEL_PATH = '/controlpanel/identity-providers';
|
|
89
|
+
|
|
90
|
+
/** Where a Manager configures the OAuth clients this site issues tokens to. */
|
|
91
|
+
export const CLIENTS_CONTROLPANEL_PATH = '/controlpanel/identity-clients';
|
|
92
|
+
|
|
93
|
+
export default function install(config: ConfigType) {
|
|
94
|
+
config.settings.nonContentRoutes = [
|
|
95
|
+
...(config.settings.nonContentRoutes ?? []),
|
|
96
|
+
new RegExp(`^${CALLBACK_PATH}$`),
|
|
97
|
+
new RegExp(`^${FIRST_LOGIN_PATH}$`),
|
|
98
|
+
new RegExp(`^${IDENTITIES_PATH}$`),
|
|
99
|
+
new RegExp(`^${CONSENT_PATH}$`),
|
|
100
|
+
new RegExp(`^${APPLICATIONS_PATH}$`),
|
|
101
|
+
// Not covered by Volto's own `/login` entry: `nonContentRoutes`
|
|
102
|
+
// strings are tested as unanchored regexes, and `/login` does not
|
|
103
|
+
// occur in `/fallback_login`.
|
|
104
|
+
new RegExp(`^${FALLBACK_LOGIN_PATH}$`),
|
|
105
|
+
];
|
|
106
|
+
config.addonRoutes = [
|
|
107
|
+
...(config.addonRoutes ?? []),
|
|
108
|
+
{ path: CALLBACK_PATH, exact: true, component: Callback },
|
|
109
|
+
{ path: FIRST_LOGIN_PATH, exact: true, component: FirstLogin },
|
|
110
|
+
{ path: FALLBACK_LOGIN_PATH, exact: true, component: VoltoLogin },
|
|
111
|
+
{ path: '/login', exact: true, component: Login },
|
|
112
|
+
{ path: '/**/login', exact: true, component: Login },
|
|
113
|
+
{ path: IDENTITIES_PATH, exact: true, component: Identities },
|
|
114
|
+
{ path: CONSENT_PATH, exact: true, component: Consent },
|
|
115
|
+
{ path: APPLICATIONS_PATH, exact: true, component: Applications },
|
|
116
|
+
{ path: USER_ACCOUNT_PATH, exact: true, component: UserAccount },
|
|
117
|
+
{ path: CONTROLPANEL_PATH, exact: true, component: ProvidersControlPanel },
|
|
118
|
+
{
|
|
119
|
+
path: CLIENTS_CONTROLPANEL_PATH,
|
|
120
|
+
exact: true,
|
|
121
|
+
component: ClientsControlPanel,
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// Keyed by configlet id, which is the last segment of each panel's route
|
|
126
|
+
// and what the backend's `controlpanel.xml` calls it. Without an entry
|
|
127
|
+
// Volto draws its generic placeholder, and a control-panel listing where
|
|
128
|
+
// this add-on's two panels are the only unlabelled tiles reads as two
|
|
129
|
+
// things that did not finish installing.
|
|
130
|
+
//
|
|
131
|
+
// A globe for the providers, because every one of them is somewhere else;
|
|
132
|
+
// the apps grid for the clients, because that panel is a list of
|
|
133
|
+
// applications this site issues tokens to. Both are Volto's own icons
|
|
134
|
+
// rather than anything drawn here: a control panel that looks like the
|
|
135
|
+
// control panels beside it is the whole point.
|
|
136
|
+
config.settings.controlPanelsIcons = {
|
|
137
|
+
...config.settings.controlPanelsIcons,
|
|
138
|
+
'identity-providers': worldSVG,
|
|
139
|
+
'identity-clients': appsSVG,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return config;
|
|
143
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { asBoolean } from './settings';
|
|
4
|
+
|
|
5
|
+
describe('asBoolean', () => {
|
|
6
|
+
it('falls back when the variable is not set', () => {
|
|
7
|
+
expect(asBoolean(undefined, false)).toBe(false);
|
|
8
|
+
expect(asBoolean(undefined, true)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('treats an empty value as unset', () => {
|
|
12
|
+
// `docker compose` writes an empty string for a variable named with no
|
|
13
|
+
// value, which means "I said nothing", not "I said no".
|
|
14
|
+
expect(asBoolean('', true)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each(['1', 'true', 'TRUE', 'yes', 'on', ' true '])(
|
|
18
|
+
'reads %o as on',
|
|
19
|
+
(value) => {
|
|
20
|
+
expect(asBoolean(value, false)).toBe(true);
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
it.each(['0', 'false', 'FALSE', 'no', 'off'])('reads %o as off', (value) => {
|
|
25
|
+
// The one that matters: `Boolean("false")` is `true`, which would turn an
|
|
26
|
+
// operator switching the password form off into a site that still has it.
|
|
27
|
+
expect(asBoolean(value, true)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reads anything it does not recognise as off', () => {
|
|
31
|
+
// Rather than as on: the values this gates are ones a site opts into.
|
|
32
|
+
expect(asBoolean('maybe', true)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|