@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,90 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { render } from '../testing';
|
|
3
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
4
|
+
import { CookiesProvider } from 'react-cookie';
|
|
5
|
+
import { Provider } from 'react-redux';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
|
|
8
|
+
import { ensureToolbarHost, PAGE_WIDTH } from './withPage';
|
|
9
|
+
import { VOLTO_CHROME } from '../stories/fixtures';
|
|
10
|
+
import Identities from '../components/Identities/Identities';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the story environment has to be able to do.
|
|
14
|
+
*
|
|
15
|
+
* Five pages here portal Volto's `Toolbar` into `#toolbar` and are wrapped in
|
|
16
|
+
* `withCookies`. Every other test of those pages stubs the toolbar out, which
|
|
17
|
+
* is right for a unit test and means nothing here would notice the *stories*
|
|
18
|
+
* being unrenderable -- which they were, for both reasons at once.
|
|
19
|
+
*
|
|
20
|
+
* So this renders one of them for real, through the same helper the decorator
|
|
21
|
+
* uses and the same provider it supplies. It is a test of the story
|
|
22
|
+
* environment, not of the page.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const LOADED = { loading: false, loaded: true, error: null };
|
|
26
|
+
|
|
27
|
+
function storyState() {
|
|
28
|
+
return {
|
|
29
|
+
identities: { ...LOADED, data: [] },
|
|
30
|
+
linkableProviders: { ...LOADED, data: [] },
|
|
31
|
+
loginProviders: { ...LOADED, data: [] },
|
|
32
|
+
myProfile: {
|
|
33
|
+
...LOADED,
|
|
34
|
+
data: { profile: '/identity-profiles/erico', emails: [] },
|
|
35
|
+
},
|
|
36
|
+
identityLinking: {},
|
|
37
|
+
identityUnlink: {},
|
|
38
|
+
preferredEmail: {},
|
|
39
|
+
...VOLTO_CHROME,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('the story page environment', () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
document.getElementById('toolbar')?.remove();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('gives the toolbar somewhere to go', () => {
|
|
49
|
+
ensureToolbarHost();
|
|
50
|
+
|
|
51
|
+
expect(document.getElementById('toolbar')).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('makes only one, however often it is asked', () => {
|
|
55
|
+
ensureToolbarHost();
|
|
56
|
+
ensureToolbarHost();
|
|
57
|
+
|
|
58
|
+
expect(document.querySelectorAll('#toolbar')).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders a page that portals its toolbar', () => {
|
|
62
|
+
// Without the host this threw "Target container is not a DOM element";
|
|
63
|
+
// without the provider, "Cannot read properties of null (reading
|
|
64
|
+
// 'getAll')" out of react-cookie. Both were the whole story failing to
|
|
65
|
+
// render, and neither was visible from the page's own tests.
|
|
66
|
+
ensureToolbarHost();
|
|
67
|
+
const state = storyState();
|
|
68
|
+
const store = {
|
|
69
|
+
getState: () => state,
|
|
70
|
+
dispatch: (action: unknown) => action,
|
|
71
|
+
subscribe: () => () => {},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
render(
|
|
75
|
+
<CookiesProvider>
|
|
76
|
+
<Provider store={store as never}>
|
|
77
|
+
<MemoryRouter initialEntries={['/identities']}>
|
|
78
|
+
<Identities />
|
|
79
|
+
</MemoryRouter>
|
|
80
|
+
</Provider>
|
|
81
|
+
</CookiesProvider>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(document.querySelector('#page-identities')).toBeTruthy();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('has a measure to constrain a story to', () => {
|
|
88
|
+
expect(PAGE_WIDTH).toMatch(/^\d+px$/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page context a story does not otherwise get.
|
|
3
|
+
*
|
|
4
|
+
* Modelled on `volto-light-theme`'s `withTheme`: one global decorator that
|
|
5
|
+
* puts every story in the surroundings its component has on a real page,
|
|
6
|
+
* rather than each story reinventing them. Three things a bare story lacks.
|
|
7
|
+
*
|
|
8
|
+
* 1. **Somewhere for the toolbar to go.** Five pages here portal Volto's
|
|
9
|
+
* `Toolbar` into `#toolbar`, an element Volto's app shell renders and
|
|
10
|
+
* Storybook does not. `createPortal` is called during render and throws on
|
|
11
|
+
* a null container, so those stories did not render at all -- which is what
|
|
12
|
+
* "the Identities story is broken" was.
|
|
13
|
+
*
|
|
14
|
+
* 2. **A cookie provider.** Volto's `Toolbar` is wrapped in `withCookies`, and
|
|
15
|
+
* without a provider above it `react-cookie` throws reading a jar that is
|
|
16
|
+
* not there. `start-client.jsx` wraps the whole app in one, so this is the
|
|
17
|
+
* real shell rather than a prop for the stories' benefit.
|
|
18
|
+
*
|
|
19
|
+
* 3. **A measure.** On the canvas a story stretches to the viewport, so a
|
|
20
|
+
* panel meant to be read at a page's width was seen at twice it. This
|
|
21
|
+
* constrains and centres, the way the theme's decorator does.
|
|
22
|
+
*
|
|
23
|
+
* A story that is genuinely full-bleed -- or that supplies its own container,
|
|
24
|
+
* as everything inside `LoginPanel` does -- opts out with
|
|
25
|
+
* `parameters: { fullBleed: true }`.
|
|
26
|
+
* @module storybook/withPage
|
|
27
|
+
*/
|
|
28
|
+
import React from 'react';
|
|
29
|
+
import type { Decorator } from '@storybook/react';
|
|
30
|
+
import { CookiesProvider } from 'react-cookie';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The width these pages are read at.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately not a restatement of Semantic UI's container width: that is
|
|
36
|
+
* computed from breakpoints and gutters rather than written down as a number,
|
|
37
|
+
* so quoting one here would be an invention that looked like a fact. The
|
|
38
|
+
* pages that use a `Container` still constrain themselves inside this; what
|
|
39
|
+
* this is for is the panels and lists that do not, which were edge-to-edge.
|
|
40
|
+
*/
|
|
41
|
+
export const PAGE_WIDTH = '1000px';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Make sure `#toolbar` exists before any story renders into it.
|
|
45
|
+
*
|
|
46
|
+
* At module scope rather than in an effect: `createPortal` reads the element
|
|
47
|
+
* during the story's *render*, which happens before any effect of a decorator
|
|
48
|
+
* above it has run. Appended to `document.body`, which is where Volto's app
|
|
49
|
+
* shell puts it and what its fixed positioning expects.
|
|
50
|
+
*
|
|
51
|
+
* There can only be one, because Volto's toolbar finds it by
|
|
52
|
+
* `getElementById`. `withUserMenu` therefore renders *into* this element
|
|
53
|
+
* rather than making a second one -- the menu styles need `#toolbar` as an
|
|
54
|
+
* ancestor, and a duplicate id would shadow this for the pages that portal
|
|
55
|
+
* their toolbar here.
|
|
56
|
+
*
|
|
57
|
+
* @returns The host, or null where there is no document.
|
|
58
|
+
*/
|
|
59
|
+
export function ensureToolbarHost(): HTMLElement | null {
|
|
60
|
+
if (typeof document === 'undefined') {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const existing = document.getElementById('toolbar');
|
|
64
|
+
if (existing) {
|
|
65
|
+
return existing;
|
|
66
|
+
}
|
|
67
|
+
const host = document.createElement('div');
|
|
68
|
+
host.id = 'toolbar';
|
|
69
|
+
document.body.appendChild(host);
|
|
70
|
+
return host;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ensureToolbarHost();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Constrain and centre a story, unless it brings its own container.
|
|
77
|
+
*
|
|
78
|
+
* @param fullBleed Whether the story fills whatever it is given.
|
|
79
|
+
* @param children The story.
|
|
80
|
+
* @returns The wrapper, or the story unchanged.
|
|
81
|
+
*/
|
|
82
|
+
const PageShell: React.FC<{
|
|
83
|
+
fullBleed: boolean;
|
|
84
|
+
children: React.ReactNode;
|
|
85
|
+
}> = ({ fullBleed, children }) => {
|
|
86
|
+
if (fullBleed) {
|
|
87
|
+
return <>{children}</>;
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
style={{
|
|
92
|
+
maxWidth: PAGE_WIDTH,
|
|
93
|
+
padding: '1rem',
|
|
94
|
+
margin: '0 auto',
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const withPage: Decorator = (Story, context) => (
|
|
103
|
+
<CookiesProvider>
|
|
104
|
+
<PageShell fullBleed={Boolean(context.parameters?.fullBleed)}>
|
|
105
|
+
<Story />
|
|
106
|
+
</PageShell>
|
|
107
|
+
</CookiesProvider>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
export default withPage;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { render } from '../testing';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
withMenuList,
|
|
7
|
+
withPersonalTools,
|
|
8
|
+
withToolbar,
|
|
9
|
+
withToolbarButton,
|
|
10
|
+
} from './withUserMenu';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* That the menu markup lands where its stylesheet can reach it.
|
|
14
|
+
*
|
|
15
|
+
* Every rule for `.pastanaga-menu`, `.pastanaga-menu-list` and `.toolbar` is
|
|
16
|
+
* nested under `#toolbar` in Volto's `toolbar.less`, so the class names alone
|
|
17
|
+
* style nothing. A story with the classes and no ancestor renders an
|
|
18
|
+
* unstyled column of links and looks like a broken component -- which is what
|
|
19
|
+
* these decorators exist to stop, and what nothing else here would notice.
|
|
20
|
+
*
|
|
21
|
+
* There must also be exactly one `#toolbar`: Volto's pages find it with
|
|
22
|
+
* `getElementById` to portal their own toolbar into.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A story, as Storybook hands one to a decorator. */
|
|
26
|
+
const story = () => <a href="/identities">Sign-in methods</a>;
|
|
27
|
+
const context = {} as never;
|
|
28
|
+
|
|
29
|
+
describe('the user-menu decorators', () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
document.getElementById('toolbar')?.remove();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('puts the panel inside #toolbar', () => {
|
|
35
|
+
render(<>{withPersonalTools(story, context)}</>);
|
|
36
|
+
|
|
37
|
+
const panel = document.querySelector('.personal-tools.pastanaga-menu');
|
|
38
|
+
expect(panel).toBeTruthy();
|
|
39
|
+
expect(panel?.closest('#toolbar')).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('puts the list where the panel styles it', () => {
|
|
43
|
+
render(
|
|
44
|
+
<>{withPersonalTools(() => withMenuList(story, context), context)}</>,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const entry = document.querySelector('#toolbar .pastanaga-menu-list ul a');
|
|
48
|
+
expect(entry).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('puts the avatar on the toolbar button, inside #toolbar', () => {
|
|
52
|
+
render(<>{withToolbarButton(story, context)}</>);
|
|
53
|
+
|
|
54
|
+
const button = document.querySelector('button#toolbar-personal.user');
|
|
55
|
+
expect(button).toBeTruthy();
|
|
56
|
+
expect(button?.closest('#toolbar .toolbar')).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('gives a component that brings its own markup just the ancestor', () => {
|
|
60
|
+
render(<>{withToolbar(story, context)}</>);
|
|
61
|
+
|
|
62
|
+
expect(document.querySelector('#toolbar a')).toBeTruthy();
|
|
63
|
+
expect(document.querySelector('#toolbar .pastanaga-menu')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('never makes a second #toolbar', () => {
|
|
67
|
+
// A duplicate earlier in the document would shadow the host that pages
|
|
68
|
+
// portal their toolbar into, and `getElementById` would hand them this.
|
|
69
|
+
render(
|
|
70
|
+
<>
|
|
71
|
+
{withPersonalTools(story, context)}
|
|
72
|
+
{withToolbarButton(story, context)}
|
|
73
|
+
</>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(document.querySelectorAll('#toolbar')).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two places this add-on puts something in Volto's toolbar.
|
|
3
|
+
*
|
|
4
|
+
* Both are markup the components land *inside* rather than markup they
|
|
5
|
+
* render, and the whole of Volto's toolbar stylesheet is nested under an id:
|
|
6
|
+
*
|
|
7
|
+
* ```less
|
|
8
|
+
* #toolbar {
|
|
9
|
+
* .toolbar { … }
|
|
10
|
+
* .pastanaga-menu { width: 100vw; height: calc(100vh - 100px); … }
|
|
11
|
+
* .pastanaga-menu-list { … }
|
|
12
|
+
* }
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* So `#toolbar` is not decoration -- without that ancestor **none** of these
|
|
16
|
+
* rules match, and a story showing the right class names still shows an
|
|
17
|
+
* unstyled list. That is what made the menu stories look wrong, and it is why
|
|
18
|
+
* `PersonalTools`, which renders `.personal-tools.pastanaga-menu` itself,
|
|
19
|
+
* looked wrong even though it carries the markup already.
|
|
20
|
+
*
|
|
21
|
+
* There can be only one `#toolbar`: Volto's pages find it with
|
|
22
|
+
* `getElementById` to portal their toolbar into, and a second one earlier in
|
|
23
|
+
* the document would shadow it. So these decorators render *into* the one
|
|
24
|
+
* `withPage` guarantees rather than making their own -- which is also what
|
|
25
|
+
* happens on a real site, where the menu is a child of the toolbar it slides
|
|
26
|
+
* over.
|
|
27
|
+
*
|
|
28
|
+
* The nesting, read out of `PersonalTools.jsx` and this package's
|
|
29
|
+
* `Toolbar.jsx`:
|
|
30
|
+
*
|
|
31
|
+
* ```
|
|
32
|
+
* <div id="toolbar"> <- withToolbar
|
|
33
|
+
* <div class="personal-tools pastanaga-menu"> <- withPersonalTools
|
|
34
|
+
* <header class="header">…</header>
|
|
35
|
+
* <div class="pastanaga-menu-list">
|
|
36
|
+
* <ul>…entries…</ul> <- withMenuList
|
|
37
|
+
*
|
|
38
|
+
* <div class="toolbar"> <- withToolbarButton
|
|
39
|
+
* …<button class="user" id="toolbar-personal">…avatar…
|
|
40
|
+
* ```
|
|
41
|
+
* @module storybook/withUserMenu
|
|
42
|
+
*/
|
|
43
|
+
import React from 'react';
|
|
44
|
+
import { createPortal } from 'react-dom';
|
|
45
|
+
import type { Decorator } from '@storybook/react';
|
|
46
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
47
|
+
|
|
48
|
+
import { ensureToolbarHost } from './withPage';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render children inside the one `#toolbar` there is.
|
|
52
|
+
*
|
|
53
|
+
* A portal rather than a wrapper element, so nothing has to invent a second
|
|
54
|
+
* element with that id. What is rendered still belongs to the story's React
|
|
55
|
+
* tree -- context, the router and the store all reach it -- and appears in
|
|
56
|
+
* the preview's own document, so the canvas shows it.
|
|
57
|
+
*/
|
|
58
|
+
const InToolbar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
59
|
+
const host = ensureToolbarHost();
|
|
60
|
+
return host ? createPortal(children, host) : null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `#toolbar` ancestor every rule in `toolbar.less` is nested under.
|
|
65
|
+
*
|
|
66
|
+
* For a component that already renders its own toolbar markup -- the
|
|
67
|
+
* shadowed `PersonalTools` is the whole of that -- and the base the two
|
|
68
|
+
* decorators below build on.
|
|
69
|
+
*/
|
|
70
|
+
export const withToolbar: Decorator = (Story) => (
|
|
71
|
+
<MemoryRouter>
|
|
72
|
+
<InToolbar>
|
|
73
|
+
<Story />
|
|
74
|
+
</InToolbar>
|
|
75
|
+
</MemoryRouter>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The personal-tools panel, down to the list its entries sit in.
|
|
80
|
+
*
|
|
81
|
+
* Stops short of the `<ul>` because the plug stories have to put it inside
|
|
82
|
+
* their `PluggablesProvider` -- a plug and the pluggable that consumes it
|
|
83
|
+
* must share one provider, and the pluggable is what renders the entries.
|
|
84
|
+
* `PluggablesProvider` contributes no DOM, so the nesting Volto's stylesheet
|
|
85
|
+
* keys on comes out the same either way.
|
|
86
|
+
*/
|
|
87
|
+
export const withPersonalTools: Decorator = (Story) => (
|
|
88
|
+
<MemoryRouter>
|
|
89
|
+
<InToolbar>
|
|
90
|
+
<div className="personal-tools pastanaga-menu">
|
|
91
|
+
<div className="pastanaga-menu-list">
|
|
92
|
+
<Story />
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</InToolbar>
|
|
96
|
+
</MemoryRouter>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The list itself, for a story that renders one entry rather than a plug.
|
|
101
|
+
*
|
|
102
|
+
* Compose it inside `withPersonalTools`: Storybook applies the first
|
|
103
|
+
* decorator innermost, so `[withMenuList, withPersonalTools]` reads in the
|
|
104
|
+
* order the DOM nests.
|
|
105
|
+
*/
|
|
106
|
+
export const withMenuList: Decorator = (Story) => (
|
|
107
|
+
<ul>
|
|
108
|
+
<Story />
|
|
109
|
+
</ul>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The toolbar button the avatar is drawn on.
|
|
114
|
+
*
|
|
115
|
+
* `#toolbar-personal` is the button this add-on's `Toolbar.jsx` replaces
|
|
116
|
+
* Volto's camera icon in, so this is where the avatar is actually seen --
|
|
117
|
+
* 30px, on the toolbar's own ground, rather than floating on the canvas.
|
|
118
|
+
* `expanded` because the toolbar is 20px tall until it is.
|
|
119
|
+
*/
|
|
120
|
+
export const withToolbarButton: Decorator = (Story) => (
|
|
121
|
+
<MemoryRouter>
|
|
122
|
+
<InToolbar>
|
|
123
|
+
<div className="toolbar expanded">
|
|
124
|
+
<div className="toolbar-body">
|
|
125
|
+
<div className="toolbar-actions">
|
|
126
|
+
<div className="toolbar-bottom">
|
|
127
|
+
<button className="user" id="toolbar-personal" type="button">
|
|
128
|
+
<Story />
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</InToolbar>
|
|
135
|
+
</MemoryRouter>
|
|
136
|
+
);
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root stylesheet for volto-identity.
|
|
3
|
+
*
|
|
4
|
+
* Two things live here and nothing else: the design tokens every component
|
|
5
|
+
* reads, and the handful of base classes more than one component wears.
|
|
6
|
+
* Everything that styles one component lives next to it, in a `.scss` that
|
|
7
|
+
* component imports itself -- the shape `volto-authomatic` uses, so a rule is
|
|
8
|
+
* found by opening the file whose markup it styles rather than by scrolling
|
|
9
|
+
* one long stylesheet.
|
|
10
|
+
*
|
|
11
|
+
* The tokens are deliberately few, and structural: layout, spacing and the
|
|
12
|
+
* state colours a user has to be able to tell apart. Typography and form
|
|
13
|
+
* controls inherit from whatever theme the site runs, so the add-on looks
|
|
14
|
+
* like part of the site rather than like itself.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
:root {
|
|
18
|
+
/* Rhythm ---------------------------------------------------------------- */
|
|
19
|
+
--identity-gap: 12px;
|
|
20
|
+
--identity-radius: 4px;
|
|
21
|
+
--identity-border: 1px solid rgba(0, 0, 0, 0.15);
|
|
22
|
+
|
|
23
|
+
/* Palette. The accent and the danger colour are volto-authomatic's, so a
|
|
24
|
+
user who has seen one of these login pages recognises the other. */
|
|
25
|
+
--identity-accent: #007eb1;
|
|
26
|
+
--identity-danger: #ee0000;
|
|
27
|
+
--identity-muted: rgba(0, 0, 0, 0.6);
|
|
28
|
+
--identity-error: #a01c1c;
|
|
29
|
+
--identity-ok: #1c7a3e;
|
|
30
|
+
|
|
31
|
+
/* Surfaces: the card the login form sits in, and the panels the control
|
|
32
|
+
panel and the identities page are built from. */
|
|
33
|
+
/* Form fields. `@plone/components`' TextField.css draws its border with
|
|
34
|
+
`var(--border-color)`, which is defined in that package's theme.css -- a
|
|
35
|
+
stylesheet this add-on never loads, since it imports the widget's CSS on
|
|
36
|
+
its own. The custom property is therefore undefined, the `border`
|
|
37
|
+
shorthand is invalid at computed-value time, and what should have been a
|
|
38
|
+
grey underline resolved to no border at all. So the fields declare their
|
|
39
|
+
own. */
|
|
40
|
+
--identity-field-border: rgba(0, 0, 0, 0.3);
|
|
41
|
+
|
|
42
|
+
--identity-surface-bg: #fff;
|
|
43
|
+
--identity-surface-border: #c7d5d8;
|
|
44
|
+
--identity-surface-header-bg: #edf1f2;
|
|
45
|
+
--identity-surface-shadow: 0 2px 4px 0 rgba(34, 36, 38, 0.12),
|
|
46
|
+
0 2px 10px 0 rgba(34, 36, 38, 0.15);
|
|
47
|
+
|
|
48
|
+
/* The login card, sized the way Volto's own login page is. */
|
|
49
|
+
--identity-login-width: 350px;
|
|
50
|
+
|
|
51
|
+
/* The card's body never gets shorter than this. Every state of the login
|
|
52
|
+
page occupies it -- the picker, either form, a wait, a refusal -- and
|
|
53
|
+
without a floor the card resized on each of them, which read as the page
|
|
54
|
+
jumping about rather than as one card changing what it says. */
|
|
55
|
+
--identity-login-min-height: 260px;
|
|
56
|
+
|
|
57
|
+
/* What a wait or a refusal is drawn on: the card's own grey, near enough
|
|
58
|
+
opaque. Not white, which read as a hole in the card rather than as
|
|
59
|
+
something over it; and not transparent enough to leave the buttons
|
|
60
|
+
underneath looking pressable while they are not. */
|
|
61
|
+
--identity-overlay-veil: rgba(237, 241, 242, 0.96);
|
|
62
|
+
|
|
63
|
+
/* Provider buttons: geometry and per-provider colours, also from
|
|
64
|
+
volto-authomatic. */
|
|
65
|
+
--identity-provider-width: 300px;
|
|
66
|
+
--identity-provider-height: 45px;
|
|
67
|
+
--identity-provider-radius: 3px;
|
|
68
|
+
--identity-provider-shadow: rgba(0, 0, 0, 0.5) 0 1px 2px;
|
|
69
|
+
|
|
70
|
+
--identity-provider-default-bg: #000;
|
|
71
|
+
--identity-provider-default-color: #fff;
|
|
72
|
+
|
|
73
|
+
--identity-provider-github-bg: #333;
|
|
74
|
+
--identity-provider-github-bg-hover: #555;
|
|
75
|
+
--identity-provider-github-color: #fff;
|
|
76
|
+
|
|
77
|
+
--identity-provider-google-bg: #fff;
|
|
78
|
+
--identity-provider-google-bg-hover: #eff0ee;
|
|
79
|
+
--identity-provider-google-color: #000;
|
|
80
|
+
|
|
81
|
+
/* The local password form is offered as one of these. The colours are
|
|
82
|
+
volto-authomatic's `plone` provider, so the button a visitor presses to
|
|
83
|
+
sign in with a site account looks the same in both add-ons. */
|
|
84
|
+
--identity-provider-plone-bg: #0083be;
|
|
85
|
+
--identity-provider-plone-bg-hover: #006c9e;
|
|
86
|
+
--identity-provider-plone-color: #fff;
|
|
87
|
+
|
|
88
|
+
--identity-provider-oidc-bg: #333;
|
|
89
|
+
--identity-provider-oidc-bg-hover: #555;
|
|
90
|
+
--identity-provider-oidc-color: #fff;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Base classes ------------------------------------------------------------
|
|
94
|
+
*
|
|
95
|
+
* Worn explicitly by the markup. They used to be selector lists naming every
|
|
96
|
+
* component that wanted the look, which meant a new component silently got
|
|
97
|
+
* none of it until somebody remembered to extend the list.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/** A bordered, shadowed card: the login panel and each control-panel section. */
|
|
101
|
+
.identity-surface {
|
|
102
|
+
border: 1px solid var(--identity-surface-border);
|
|
103
|
+
background: var(--identity-surface-bg);
|
|
104
|
+
box-shadow: var(--identity-surface-shadow);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Its titled header strip. */
|
|
108
|
+
.identity-surface__header {
|
|
109
|
+
padding: 12px 16px;
|
|
110
|
+
border-bottom: 0.22222222rem solid var(--identity-surface-border);
|
|
111
|
+
margin: 0;
|
|
112
|
+
font-size: 16px;
|
|
113
|
+
font-weight: 500;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every button this add-on renders itself, rather than through Volto. */
|
|
117
|
+
.identity-button {
|
|
118
|
+
padding: 8px 14px;
|
|
119
|
+
border: 1px solid var(--identity-accent);
|
|
120
|
+
border-radius: var(--identity-radius);
|
|
121
|
+
background: transparent;
|
|
122
|
+
color: var(--identity-accent);
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
font-size: 14px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.identity-button:hover:not(:disabled) {
|
|
128
|
+
background: var(--identity-surface-header-bg);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.identity-button:disabled {
|
|
132
|
+
border-color: var(--identity-muted);
|
|
133
|
+
color: var(--identity-muted);
|
|
134
|
+
cursor: default;
|
|
135
|
+
opacity: 0.6;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The affirmative one, where a screen asks a yes-or-no question. */
|
|
139
|
+
.identity-button--primary {
|
|
140
|
+
background: var(--identity-accent);
|
|
141
|
+
color: var(--identity-surface-bg);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.identity-button--primary:hover:not(:disabled) {
|
|
145
|
+
background: var(--identity-accent);
|
|
146
|
+
opacity: 0.9;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The irreversible ones say so. */
|
|
150
|
+
.identity-button--danger {
|
|
151
|
+
border-color: var(--identity-danger);
|
|
152
|
+
color: var(--identity-danger);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A refusal, wherever it is reported. */
|
|
156
|
+
.identity-error {
|
|
157
|
+
margin: 0;
|
|
158
|
+
color: var(--identity-error);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Secondary text: an empty state, or what a control costs. */
|
|
162
|
+
.identity-note {
|
|
163
|
+
color: var(--identity-muted);
|
|
164
|
+
font-size: 0.9em;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A page split into sections a reader picks between.
|
|
169
|
+
*
|
|
170
|
+
* Worn by the `Tabs` from `@plone/components`, whose own `Tabs.css` each page
|
|
171
|
+
* imports beside this -- the same split the fields use, and for the same
|
|
172
|
+
* reason written at `--identity-field-border` above. This adds only what
|
|
173
|
+
* makes the tabs look like the rest of the add-on: the surface's border under
|
|
174
|
+
* the strip, and the accent under whichever tab is current.
|
|
175
|
+
*
|
|
176
|
+
* Two pages wear it -- a user's account in the control panel, and the
|
|
177
|
+
* sign-in methods page a person manages their own with. They are the same
|
|
178
|
+
* question asked by an administrator and by the account's owner, so they are
|
|
179
|
+
* the same design, described once.
|
|
180
|
+
*/
|
|
181
|
+
.identity-tabs .react-aria-TabList {
|
|
182
|
+
display: flex;
|
|
183
|
+
flex-direction: row;
|
|
184
|
+
border-bottom: 1px solid var(--identity-surface-border);
|
|
185
|
+
gap: 4px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.identity-tabs .react-aria-Tab {
|
|
189
|
+
padding: 10px 16px;
|
|
190
|
+
border-bottom: 3px solid transparent;
|
|
191
|
+
margin-bottom: -1px;
|
|
192
|
+
color: var(--identity-muted);
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
font-size: 14px;
|
|
195
|
+
font-weight: 500;
|
|
196
|
+
outline: none;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.identity-tabs .react-aria-Tab[data-hovered] {
|
|
200
|
+
color: var(--identity-accent);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.identity-tabs .react-aria-Tab[data-selected] {
|
|
204
|
+
border-bottom-color: var(--identity-accent);
|
|
205
|
+
color: var(--identity-accent);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* Keyboard focus has to be visible even on the tab already selected, where
|
|
209
|
+
the accent underline says nothing about where focus is. */
|
|
210
|
+
.identity-tabs .react-aria-Tab[data-focus-visible] {
|
|
211
|
+
outline: 2px solid var(--identity-accent);
|
|
212
|
+
outline-offset: -2px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.identity-tabs .react-aria-Tab[data-disabled] {
|
|
216
|
+
color: var(--identity-muted);
|
|
217
|
+
cursor: default;
|
|
218
|
+
opacity: 0.5;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.identity-tabs .react-aria-TabPanel {
|
|
222
|
+
padding-top: var(--identity-gap);
|
|
223
|
+
outline: none;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A wait, drawn rather than described.
|
|
228
|
+
*
|
|
229
|
+
* Its own rule rather than `@plone/components`' `Spinner` for the reason the
|
|
230
|
+
* field border above is: that package ships its components and its CSS
|
|
231
|
+
* separately, and this add-on loads a stylesheet per widget it uses. A
|
|
232
|
+
* spinner is a border and a rotation, which is less than the note explaining
|
|
233
|
+
* why the imported one came through unstyled.
|
|
234
|
+
*
|
|
235
|
+
* Decorative: whatever is waiting says so in text beside this, and that text
|
|
236
|
+
* is what a screen reader should reach.
|
|
237
|
+
*/
|
|
238
|
+
.identity-spinner {
|
|
239
|
+
display: inline-block;
|
|
240
|
+
width: 1.15em;
|
|
241
|
+
height: 1.15em;
|
|
242
|
+
flex: none;
|
|
243
|
+
border: 2px solid var(--identity-surface-border);
|
|
244
|
+
border-radius: 50%;
|
|
245
|
+
border-top-color: var(--identity-accent);
|
|
246
|
+
animation: identity-spin 0.7s linear infinite;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@keyframes identity-spin {
|
|
250
|
+
to {
|
|
251
|
+
transform: rotate(360deg);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* A spin that cannot be stopped is a spin somebody has to look away from. */
|
|
256
|
+
@media (prefers-reduced-motion: reduce) {
|
|
257
|
+
.identity-spinner {
|
|
258
|
+
border-top-color: var(--identity-accent);
|
|
259
|
+
animation: none;
|
|
260
|
+
opacity: 0.6;
|
|
261
|
+
}
|
|
262
|
+
}
|