@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,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The personal-tools menu as an ordered list of plugs.
|
|
3
|
+
*
|
|
4
|
+
* The entries are registered from five separate components, so what nobody
|
|
5
|
+
* can see by reading any one of them is the thing worth testing: what the
|
|
6
|
+
* assembled menu contains, in what order, and which entries stand each other
|
|
7
|
+
* down.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
11
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
12
|
+
import { Provider } from 'react-redux';
|
|
13
|
+
import React from 'react';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
Pluggable,
|
|
17
|
+
PluggablesProvider,
|
|
18
|
+
} from '@plone/volto/components/manage/Pluggable';
|
|
19
|
+
import IdentitiesMenuItem from './IdentitiesMenuItem';
|
|
20
|
+
import ProfileMenuItem from './ProfileMenuItem';
|
|
21
|
+
import {
|
|
22
|
+
PersonalInformationMenuItem,
|
|
23
|
+
PreferencesMenuItem,
|
|
24
|
+
SiteSetupMenuItem,
|
|
25
|
+
} from './UserMenuPlugs';
|
|
26
|
+
|
|
27
|
+
interface MenuState {
|
|
28
|
+
/** The Profile URL `@users` reported, when the user has one. */
|
|
29
|
+
profileUrl?: string | null;
|
|
30
|
+
/** The PAS plugin the userid came from, as `@users` reports it. */
|
|
31
|
+
source?: string | null;
|
|
32
|
+
/** The user actions the backend listed. */
|
|
33
|
+
actions?: unknown[];
|
|
34
|
+
/** Slide a panel in; the prop `PersonalTools` passes through params. */
|
|
35
|
+
loadComponent?: (selector: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render the whole menu the way the application assembles it.
|
|
40
|
+
*
|
|
41
|
+
* Every plug is mounted from `appExtras` in the real thing, which is what
|
|
42
|
+
* this stands in for; the `<Pluggable>` is what the shadowed `PersonalTools`
|
|
43
|
+
* renders.
|
|
44
|
+
*
|
|
45
|
+
* @param state What the store says and what the toolbar passes down.
|
|
46
|
+
* @returns The ids of the entries, in the order they were rendered.
|
|
47
|
+
*/
|
|
48
|
+
function renderMenu(state: MenuState = {}) {
|
|
49
|
+
const {
|
|
50
|
+
profileUrl = null,
|
|
51
|
+
source = null,
|
|
52
|
+
actions = [],
|
|
53
|
+
loadComponent = vi.fn(),
|
|
54
|
+
} = state;
|
|
55
|
+
const store = {
|
|
56
|
+
getState: () => ({
|
|
57
|
+
userProfile: { data: { id: 'alice', profile_url: profileUrl, source } },
|
|
58
|
+
actions: { actions: { user: actions } },
|
|
59
|
+
}),
|
|
60
|
+
dispatch: vi.fn(),
|
|
61
|
+
subscribe: () => () => {},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
render(
|
|
65
|
+
<Provider store={store as never}>
|
|
66
|
+
<MemoryRouter>
|
|
67
|
+
<PluggablesProvider>
|
|
68
|
+
<PersonalInformationMenuItem />
|
|
69
|
+
<PreferencesMenuItem />
|
|
70
|
+
<SiteSetupMenuItem />
|
|
71
|
+
<IdentitiesMenuItem />
|
|
72
|
+
<ProfileMenuItem />
|
|
73
|
+
<ul>
|
|
74
|
+
<Pluggable name="toolbar-user-menu" params={{ loadComponent }} />
|
|
75
|
+
</ul>
|
|
76
|
+
</PluggablesProvider>
|
|
77
|
+
</MemoryRouter>
|
|
78
|
+
</Provider>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
loadComponent,
|
|
83
|
+
ids: Array.from(document.querySelectorAll('li > *')).map((el) => el.id),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A user whose account *is* a Profile content object. */
|
|
88
|
+
const STORED_AS_A_PROFILE: MenuState = {
|
|
89
|
+
profileUrl: 'http://site/profiles/alice',
|
|
90
|
+
source: 'identity_profile',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
describe('the personal-tools menu', () => {
|
|
94
|
+
it('puts sign-in methods straight after preferences', () => {
|
|
95
|
+
// Which is where it belongs: choosing how you get in is a preference,
|
|
96
|
+
// and before this the pluggable could only append after Site Setup.
|
|
97
|
+
const { ids } = renderMenu();
|
|
98
|
+
|
|
99
|
+
expect(ids).toEqual([
|
|
100
|
+
'toolbar-profile',
|
|
101
|
+
'toolbar-preferences',
|
|
102
|
+
'toolbar-identities',
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('puts Site Setup last, for somebody who has that action', () => {
|
|
107
|
+
// Last because it is about the site rather than about the person.
|
|
108
|
+
const { ids } = renderMenu({ actions: [{ id: 'plone_setup' }] });
|
|
109
|
+
|
|
110
|
+
expect(ids[ids.length - 1]).toBe('toolbar-site-setup');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('offers Site Setup to nobody else', () => {
|
|
114
|
+
const { ids } = renderMenu();
|
|
115
|
+
|
|
116
|
+
expect(ids).not.toContain('toolbar-site-setup');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('gives the Profile slot to the object the account lives in', () => {
|
|
120
|
+
// Not beside it: two entries both called "Profile" make the reader guess
|
|
121
|
+
// which one they want.
|
|
122
|
+
const { ids } = renderMenu(STORED_AS_A_PROFILE);
|
|
123
|
+
|
|
124
|
+
expect(ids).toContain('toolbar-identity-profile');
|
|
125
|
+
expect(ids).not.toContain('toolbar-profile');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('keeps it where the member form was', () => {
|
|
129
|
+
const { ids } = renderMenu(STORED_AS_A_PROFILE);
|
|
130
|
+
|
|
131
|
+
expect(ids).toEqual([
|
|
132
|
+
'toolbar-identity-profile',
|
|
133
|
+
'toolbar-preferences',
|
|
134
|
+
'toolbar-identities',
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('calls it Profile either way', () => {
|
|
139
|
+
// It stands in the other one's place rather than beside it, so it takes
|
|
140
|
+
// its name too.
|
|
141
|
+
renderMenu(STORED_AS_A_PROFILE);
|
|
142
|
+
expect(screen.getByText('Profile')).toBeTruthy();
|
|
143
|
+
|
|
144
|
+
document.body.innerHTML = '';
|
|
145
|
+
renderMenu();
|
|
146
|
+
expect(screen.getByText('Profile')).toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('does not care which plugin authenticated the account', () => {
|
|
150
|
+
// `source` names whichever plugin PAS enumerated the account from, and
|
|
151
|
+
// that changes with how the account was made -- so a rule keyed on it
|
|
152
|
+
// hid the entry for whole classes of user. `profile_url` is the
|
|
153
|
+
// question the menu actually has.
|
|
154
|
+
const { ids } = renderMenu({
|
|
155
|
+
profileUrl: 'http://site/profiles/alice',
|
|
156
|
+
source: 'source_users',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(ids).toContain('toolbar-identity-profile');
|
|
160
|
+
expect(ids).not.toContain('toolbar-profile');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('falls back to the member form when there is no Profile', () => {
|
|
164
|
+
// An account that predates the add-on, or a user whose first login has
|
|
165
|
+
// not minted a Profile yet.
|
|
166
|
+
const { ids } = renderMenu();
|
|
167
|
+
|
|
168
|
+
expect(ids).toContain('toolbar-profile');
|
|
169
|
+
expect(ids).not.toContain('toolbar-identity-profile');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('needs a Profile to link to', () => {
|
|
173
|
+
// A menu entry leading nowhere is worse than no menu entry.
|
|
174
|
+
const { ids } = renderMenu({ source: 'identity_profile' });
|
|
175
|
+
|
|
176
|
+
expect(ids).toContain('toolbar-profile');
|
|
177
|
+
expect(ids).not.toContain('toolbar-identity-profile');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('gives every entry an id that does not change with the language', () => {
|
|
181
|
+
// Upstream used the translated label as the DOM id, so anything keying
|
|
182
|
+
// on it worked in English and silently not in Portuguese.
|
|
183
|
+
const { ids } = renderMenu({
|
|
184
|
+
...STORED_AS_A_PROFILE,
|
|
185
|
+
actions: [{ id: 'plone_setup' }],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
expect(id.startsWith('toolbar-')).toBe(true);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('slides the preferences panel in through the toolbar it came from', () => {
|
|
194
|
+
// The one entry that is not a link. `loadComponent` belongs to
|
|
195
|
+
// `PersonalTools`, and reaches this plug through the pluggable's params.
|
|
196
|
+
const { loadComponent } = renderMenu();
|
|
197
|
+
|
|
198
|
+
fireEvent.click(screen.getByRole('button', { name: 'Preferences' }));
|
|
199
|
+
|
|
200
|
+
expect(loadComponent).toHaveBeenCalledWith('preferences');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volto's own personal-tools entries, re-registered as plugs.
|
|
3
|
+
*
|
|
4
|
+
* The menu used to be three entries written into `PersonalTools` plus a
|
|
5
|
+
* pluggable at the end, which meant an add-on could only ever *append*: a
|
|
6
|
+
* site wanting "Sign-in methods" under "Preferences", or wanting no "Site
|
|
7
|
+
* Setup" link, had to shadow the component again. Here every entry is a plug
|
|
8
|
+
* with an `order`, including the ones Volto ships, so the whole menu is one
|
|
9
|
+
* ordered list a site composes rather than three fixed things and a bucket.
|
|
10
|
+
*
|
|
11
|
+
* The orders are spaced by ten, so an add-on can land between two of them
|
|
12
|
+
* without renumbering anything:
|
|
13
|
+
*
|
|
14
|
+
* ==== ==========================================================
|
|
15
|
+
* 10 The profile -- this package's, when the user has one;
|
|
16
|
+
* Volto's member form otherwise. Never both: see below.
|
|
17
|
+
* 20 Preferences
|
|
18
|
+
* 30 Sign-in methods, which is asked for right after preferences
|
|
19
|
+
* because it *is* a preference: it is where a person adds or
|
|
20
|
+
* drops a way of getting in.
|
|
21
|
+
* 35 Applications, on a site running the `[server]` layer -- the
|
|
22
|
+
* same question the other way round, so it sits next to it.
|
|
23
|
+
* A number between two of these is exactly what the spacing
|
|
24
|
+
* is for.
|
|
25
|
+
* 40 Site Setup, for a manager. Last because it is about the
|
|
26
|
+
* site rather than about the person.
|
|
27
|
+
* ==== ==========================================================
|
|
28
|
+
*
|
|
29
|
+
* `loadComponent` reaches these through the pluggable's `params`, which is
|
|
30
|
+
* how a plug rendered from `appExtras` gets at a prop that belongs to
|
|
31
|
+
* `PersonalTools`. A plug that needs it takes a function child; one that does
|
|
32
|
+
* not takes plain children and never sees them.
|
|
33
|
+
* @module components/UserMenu/UserMenuPlugs
|
|
34
|
+
*/
|
|
35
|
+
import React from 'react';
|
|
36
|
+
import { useSelector } from 'react-redux';
|
|
37
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
38
|
+
import { Plug } from '@plone/volto/components/manage/Pluggable';
|
|
39
|
+
|
|
40
|
+
import { profileHoldsTheFields } from '../../helpers/profileSource';
|
|
41
|
+
import MenuItem from './MenuItem';
|
|
42
|
+
|
|
43
|
+
const messages = defineMessages({
|
|
44
|
+
preferences: { id: 'Preferences', defaultMessage: 'Preferences' },
|
|
45
|
+
profile: { id: 'Profile', defaultMessage: 'Profile' },
|
|
46
|
+
siteSetup: { id: 'Site Setup', defaultMessage: 'Site Setup' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Params the personal-tools pluggable hands to every plug. */
|
|
50
|
+
export interface UserMenuParams {
|
|
51
|
+
/** Slide another panel into the toolbar, by its registered name. */
|
|
52
|
+
loadComponent: (selector: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Volto's own Profile entry, which is the member form at
|
|
57
|
+
* `/personal-information`.
|
|
58
|
+
*
|
|
59
|
+
* Absent for a user whose fields are held in a Profile, where
|
|
60
|
+
* `ProfileMenuItem` takes this slot under the same name. It keeps the slot
|
|
61
|
+
* for a user first login has not minted a Profile for, and for an account
|
|
62
|
+
* that predates the add-on -- who are exactly the users whose fields really
|
|
63
|
+
* are edited at `/personal-information`.
|
|
64
|
+
*/
|
|
65
|
+
export const PersonalInformationMenuItem: React.FC = () => {
|
|
66
|
+
const intl = useIntl();
|
|
67
|
+
// A primitive; see `ProfileMenuItem` for why.
|
|
68
|
+
const profileUrl = useSelector(
|
|
69
|
+
(state: any) => state.userProfile?.data?.profile_url,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (profileHoldsTheFields(profileUrl)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Plug
|
|
78
|
+
pluggable="toolbar-user-menu"
|
|
79
|
+
id="personal-information"
|
|
80
|
+
order={10}
|
|
81
|
+
dependencies={[profileUrl]}
|
|
82
|
+
>
|
|
83
|
+
<MenuItem
|
|
84
|
+
id="toolbar-profile"
|
|
85
|
+
label={intl.formatMessage(messages.profile)}
|
|
86
|
+
to="/personal-information"
|
|
87
|
+
/>
|
|
88
|
+
</Plug>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Volto's Preferences panel, which slides in rather than being a route. */
|
|
93
|
+
export const PreferencesMenuItem: React.FC = () => {
|
|
94
|
+
const intl = useIntl();
|
|
95
|
+
return (
|
|
96
|
+
<Plug pluggable="toolbar-user-menu" id="preferences" order={20}>
|
|
97
|
+
{({ loadComponent }: UserMenuParams) => (
|
|
98
|
+
<MenuItem
|
|
99
|
+
id="toolbar-preferences"
|
|
100
|
+
label={intl.formatMessage(messages.preferences)}
|
|
101
|
+
onClick={() => loadComponent('preferences')}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
</Plug>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Volto's Site Setup link, for a user who may see it.
|
|
110
|
+
*
|
|
111
|
+
* The condition is upstream's: the `plone_setup` user action, which the
|
|
112
|
+
* backend only lists for somebody allowed to reach the control panels. Read
|
|
113
|
+
* from the store here rather than passed through `params`, so this plug does
|
|
114
|
+
* not need `PersonalTools` to know about it.
|
|
115
|
+
*/
|
|
116
|
+
export const SiteSetupMenuItem: React.FC = () => {
|
|
117
|
+
const intl = useIntl();
|
|
118
|
+
const siteSetupAction = useSelector((state: any) =>
|
|
119
|
+
state.actions?.actions?.user?.find(
|
|
120
|
+
(action: { id?: string }) => action?.id === 'plone_setup',
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (!siteSetupAction) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Plug
|
|
130
|
+
pluggable="toolbar-user-menu"
|
|
131
|
+
id="site-setup"
|
|
132
|
+
order={40}
|
|
133
|
+
dependencies={[Boolean(siteSetupAction)]}
|
|
134
|
+
>
|
|
135
|
+
<MenuItem
|
|
136
|
+
id="toolbar-site-setup"
|
|
137
|
+
label={intl.formatMessage(messages.siteSetup)}
|
|
138
|
+
to="/controlpanel"
|
|
139
|
+
/>
|
|
140
|
+
</Plug>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { render } from '../../testing';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import UserProfileLoader from './UserProfileLoader';
|
|
7
|
+
|
|
8
|
+
/** A JWT-shaped token whose payload names `sub`. */
|
|
9
|
+
function tokenFor(sub: string): string {
|
|
10
|
+
const body = Buffer.from(JSON.stringify({ sub, exp: 9e9 }))
|
|
11
|
+
.toString('base64')
|
|
12
|
+
.replace(/\+/g, '-')
|
|
13
|
+
.replace(/\//g, '_')
|
|
14
|
+
.replace(/=+$/, '');
|
|
15
|
+
return `header.${body}.signature`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderLoader(state: Record<string, unknown>) {
|
|
19
|
+
const dispatch = vi.fn((action: unknown) => action);
|
|
20
|
+
const store = {
|
|
21
|
+
getState: () => state,
|
|
22
|
+
dispatch,
|
|
23
|
+
subscribe: () => () => {},
|
|
24
|
+
};
|
|
25
|
+
render(
|
|
26
|
+
<Provider store={store as never}>
|
|
27
|
+
<UserProfileLoader />
|
|
28
|
+
</Provider>,
|
|
29
|
+
);
|
|
30
|
+
return dispatch;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const EMPTY = { loading: false, loaded: false, error: null, data: null };
|
|
34
|
+
|
|
35
|
+
describe('UserProfileLoader', () => {
|
|
36
|
+
it('asks for the signed-in user', () => {
|
|
37
|
+
const dispatch = renderLoader({
|
|
38
|
+
userSession: { token: tokenFor('alice') },
|
|
39
|
+
userProfile: EMPTY,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(dispatch).toHaveBeenCalledWith(
|
|
43
|
+
expect.objectContaining({
|
|
44
|
+
request: { op: 'get', path: '/@users/alice' },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('asks for nothing when nobody is signed in', () => {
|
|
50
|
+
const dispatch = renderLoader({
|
|
51
|
+
userSession: { token: '' },
|
|
52
|
+
userProfile: EMPTY,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('does not ask again for a user it already holds', () => {
|
|
59
|
+
const dispatch = renderLoader({
|
|
60
|
+
userSession: { token: tokenFor('alice') },
|
|
61
|
+
userProfile: {
|
|
62
|
+
loading: false,
|
|
63
|
+
loaded: true,
|
|
64
|
+
error: null,
|
|
65
|
+
data: { id: 'alice' },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not ask while a request is in flight', () => {
|
|
73
|
+
const dispatch = renderLoader({
|
|
74
|
+
userSession: { token: tokenFor('alice') },
|
|
75
|
+
userProfile: { ...EMPTY, loading: true },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('asks again when a different user signs in', () => {
|
|
82
|
+
const dispatch = renderLoader({
|
|
83
|
+
userSession: { token: tokenFor('bob') },
|
|
84
|
+
userProfile: {
|
|
85
|
+
loading: false,
|
|
86
|
+
loaded: true,
|
|
87
|
+
error: null,
|
|
88
|
+
data: { id: 'alice' },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(dispatch).toHaveBeenCalledWith(
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
request: { op: 'get', path: '/@users/bob' },
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('survives a store where nothing has been initialised yet', () => {
|
|
100
|
+
// Both slices are read with optional chaining because this mounts on
|
|
101
|
+
// every route, including the first render of a cold store.
|
|
102
|
+
const dispatch = renderLoader({});
|
|
103
|
+
|
|
104
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('does not retry forever after a failure', () => {
|
|
108
|
+
// The request was refused -- 401 on a stale token, say. Retrying on
|
|
109
|
+
// every render would be a request per frame.
|
|
110
|
+
const dispatch = renderLoader({
|
|
111
|
+
userSession: { token: tokenFor('alice') },
|
|
112
|
+
userProfile: {
|
|
113
|
+
loading: false,
|
|
114
|
+
loaded: false,
|
|
115
|
+
error: { status: 401 },
|
|
116
|
+
data: null,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not ask again after the request failed', () => {
|
|
124
|
+
// The storm, reproduced the way it actually happened: the request fails,
|
|
125
|
+
// `loading` goes true then false again, and a guard made only of
|
|
126
|
+
// `loading` and `loadedFor` is satisfied on the very next render. One
|
|
127
|
+
// render cannot show this — the effect has to re-run, which is what the
|
|
128
|
+
// oscillation below causes.
|
|
129
|
+
const dispatch = vi.fn((action: unknown) => action);
|
|
130
|
+
const state: Record<string, unknown> = {
|
|
131
|
+
userSession: { token: tokenFor('alice') },
|
|
132
|
+
userProfile: { ...EMPTY },
|
|
133
|
+
};
|
|
134
|
+
const store = {
|
|
135
|
+
getState: () => state,
|
|
136
|
+
dispatch,
|
|
137
|
+
subscribe: () => () => {},
|
|
138
|
+
};
|
|
139
|
+
const { rerender } = render(
|
|
140
|
+
<Provider store={store as never}>
|
|
141
|
+
<UserProfileLoader />
|
|
142
|
+
</Provider>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Three failed round trips' worth of state changes.
|
|
146
|
+
for (const loading of [true, false, true, false, true, false]) {
|
|
147
|
+
state.userProfile = loading
|
|
148
|
+
? { loading: true, loaded: false, error: null, data: null }
|
|
149
|
+
: { loading: false, loaded: false, error: { status: 401 }, data: null };
|
|
150
|
+
rerender(
|
|
151
|
+
<Provider store={{ ...store, getState: () => state } as never}>
|
|
152
|
+
<UserProfileLoader />
|
|
153
|
+
</Provider>,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('does not ask again after a failure it already saw', () => {
|
|
161
|
+
// The storm. A failed request leaves `loadedFor` unset and `loading`
|
|
162
|
+
// false, so a guard made only of those two is satisfied on the very next
|
|
163
|
+
// render and the effect fires again — for ever. A token that no longer
|
|
164
|
+
// authenticates but still decodes to a userid, which is exactly what a
|
|
165
|
+
// stale token against a rebuilt site is, produced about 150 requests a
|
|
166
|
+
// second against `@users/<id>` in the demo until the tab was closed.
|
|
167
|
+
const dispatch = renderLoader({
|
|
168
|
+
userSession: { token: tokenFor('alice') },
|
|
169
|
+
userProfile: {
|
|
170
|
+
loading: false,
|
|
171
|
+
loaded: false,
|
|
172
|
+
error: { status: 401 },
|
|
173
|
+
data: null,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('asks again when the user changes', () => {
|
|
181
|
+
// One attempt per userid, not one attempt ever: signing in as somebody
|
|
182
|
+
// else has to fetch somebody else.
|
|
183
|
+
const dispatch = renderLoader({
|
|
184
|
+
userSession: { token: tokenFor('alice') },
|
|
185
|
+
userProfile: {
|
|
186
|
+
loading: false,
|
|
187
|
+
loaded: false,
|
|
188
|
+
error: { status: 401 },
|
|
189
|
+
data: null,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
const first = dispatch.mock.calls.length;
|
|
193
|
+
|
|
194
|
+
const second = renderLoader({
|
|
195
|
+
userSession: { token: tokenFor('bob') },
|
|
196
|
+
userProfile: {
|
|
197
|
+
loading: false,
|
|
198
|
+
loaded: false,
|
|
199
|
+
error: { status: 401 },
|
|
200
|
+
data: null,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(first).toBe(1);
|
|
205
|
+
expect(second).toHaveBeenCalledWith(
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
request: { op: 'get', path: '/@users/bob' },
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the signed-in user's profile in the store.
|
|
3
|
+
*
|
|
4
|
+
* Volto fetches the current user only when the personal-tools menu opens, and
|
|
5
|
+
* what it fetches lands in `state.users.user`, which the menu also clears
|
|
6
|
+
* around itself. Anything that wants to show who is signed in *outside* that
|
|
7
|
+
* menu -- an avatar in the toolbar, a link to the user's Profile -- therefore
|
|
8
|
+
* cannot rely on it being there.
|
|
9
|
+
*
|
|
10
|
+
* This mounts on every route through `appExtras`, renders nothing, and asks
|
|
11
|
+
* once per userid — once, whether the answer arrives or fails. It is the userid rather than the token that it keys on:
|
|
12
|
+
* a token is reissued on refresh without the user changing, and refetching
|
|
13
|
+
* the same person on every renewal would be a request per hour for nothing.
|
|
14
|
+
* @module components/UserMenu/UserProfileLoader
|
|
15
|
+
*/
|
|
16
|
+
import React, { useEffect, useRef } from 'react';
|
|
17
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
18
|
+
|
|
19
|
+
import { getUserProfile } from '../../actions';
|
|
20
|
+
import { useridFromToken } from '../../helpers/token';
|
|
21
|
+
|
|
22
|
+
const UserProfileLoader: React.FC = () => {
|
|
23
|
+
const dispatch = useDispatch();
|
|
24
|
+
const token = useSelector((state: any) => state.userSession?.token);
|
|
25
|
+
const loadedFor = useSelector((state: any) => state.userProfile?.data?.id);
|
|
26
|
+
const loading = useSelector((state: any) => state.userProfile?.loading);
|
|
27
|
+
const userid = useridFromToken(token);
|
|
28
|
+
const attempted = useRef<string | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Anonymous, or already holding this user. A request in flight counts as
|
|
32
|
+
// holding them: without that check the effect fires again on the render
|
|
33
|
+
// the pending state causes, and the two answers race.
|
|
34
|
+
if (!userid || loading || loadedFor === userid) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// And once per userid, however it turned out. A *failed* request leaves
|
|
38
|
+
// `loadedFor` unset and `loading` false, so the two checks above are both
|
|
39
|
+
// satisfied on the very next render and the effect fires again — for
|
|
40
|
+
// ever. A token that no longer authenticates but still decodes to a
|
|
41
|
+
// userid, which is what a stale token in a rebuilt site is, produced
|
|
42
|
+
// about 150 requests a second against `@users/<id>` until the tab was
|
|
43
|
+
// closed.
|
|
44
|
+
if (attempted.current === userid) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
attempted.current = userid;
|
|
48
|
+
dispatch(getUserProfile(userid));
|
|
49
|
+
}, [dispatch, userid, loadedFor, loading]);
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default UserProfileLoader;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A group page: what is in it, and who.
|
|
3
|
+
*
|
|
4
|
+
* Three sections with the same rhythm, so a group nested three deep and a
|
|
5
|
+
* flat one read as the same page with different amounts on it.
|
|
6
|
+
*/
|
|
7
|
+
.identity-group-view {
|
|
8
|
+
section {
|
|
9
|
+
margin-top: var(--identity-gap);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
h2 {
|
|
13
|
+
margin-bottom: 4px;
|
|
14
|
+
font-size: 1.15em;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
ul {
|
|
18
|
+
padding: 0;
|
|
19
|
+
margin: 8px 0 0;
|
|
20
|
+
list-style: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
li {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: baseline;
|
|
26
|
+
padding: 6px 0;
|
|
27
|
+
border-bottom: var(--identity-border);
|
|
28
|
+
gap: 8px;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.identity-group-view__list {
|
|
33
|
+
li a {
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
}
|
|
36
|
+
}
|