@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,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A link to the user's own Profile, plugged into Volto's user menu.
|
|
3
|
+
*
|
|
4
|
+
* Only for a user who has one -- which, as :mod:`helpers/profileSource`
|
|
5
|
+
* explains, is the same thing as their fields being held in it.
|
|
6
|
+
*
|
|
7
|
+
* For that user it *replaces* Volto's own Profile link rather than sitting
|
|
8
|
+
* beside it, and takes its name: two entries both called "Profile" make the
|
|
9
|
+
* reader guess which is which, and the content object is where their fields
|
|
10
|
+
* actually live. Taking the slot rather than adding to it is why they share
|
|
11
|
+
* an `order`, and why
|
|
12
|
+
* :func:`~components/UserMenu/UserMenuPlugs.PersonalInformationMenuItem`
|
|
13
|
+
* renders nothing once this one can.
|
|
14
|
+
* @module components/UserMenu/ProfileMenuItem
|
|
15
|
+
*/
|
|
16
|
+
import React from 'react';
|
|
17
|
+
import { useSelector } from 'react-redux';
|
|
18
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
19
|
+
import { Plug } from '@plone/volto/components/manage/Pluggable';
|
|
20
|
+
import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
|
|
21
|
+
|
|
22
|
+
import { profileHoldsTheFields } from '../../helpers/profileSource';
|
|
23
|
+
import MenuItem from './MenuItem';
|
|
24
|
+
|
|
25
|
+
const messages = defineMessages({
|
|
26
|
+
// The same label Volto's own entry carries, because this stands in its
|
|
27
|
+
// place rather than beside it.
|
|
28
|
+
title: { id: 'Profile', defaultMessage: 'Profile' },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const ProfileMenuItem: React.FC = () => {
|
|
32
|
+
const intl = useIntl();
|
|
33
|
+
// A primitive, not the user object: `useSelector` compares by identity,
|
|
34
|
+
// and selecting the user whole re-renders this on every store
|
|
35
|
+
// notification.
|
|
36
|
+
const profileUrl = useSelector(
|
|
37
|
+
(state: any) => state.userProfile?.data?.profile_url,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (!profileHoldsTheFields(profileUrl)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Plug
|
|
46
|
+
pluggable="toolbar-user-menu"
|
|
47
|
+
id="identity-profile"
|
|
48
|
+
order={10}
|
|
49
|
+
dependencies={[profileUrl]}
|
|
50
|
+
>
|
|
51
|
+
<MenuItem
|
|
52
|
+
id="toolbar-identity-profile"
|
|
53
|
+
label={intl.formatMessage(messages.title)}
|
|
54
|
+
to={flattenToAppURL(profileUrl)}
|
|
55
|
+
/>
|
|
56
|
+
</Plug>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default ProfileMenuItem;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.identity-avatar {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
flex: 0 0 auto;
|
|
5
|
+
align-items: center;
|
|
6
|
+
justify-content: center;
|
|
7
|
+
border-radius: 50%;
|
|
8
|
+
vertical-align: middle;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.identity-avatar--picture {
|
|
12
|
+
object-fit: cover;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.identity-avatar--initials {
|
|
16
|
+
color: #fff;
|
|
17
|
+
font-weight: 600;
|
|
18
|
+
// The initials are drawn, not read as text: selecting them out of a
|
|
19
|
+
// toolbar button is never what somebody meant to do.
|
|
20
|
+
letter-spacing: 0.02em;
|
|
21
|
+
line-height: 1;
|
|
22
|
+
user-select: none;
|
|
23
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Provider } from 'react-redux';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import UserAvatar from './UserAvatar';
|
|
6
|
+
import { LOADED, PORTRAIT, USER, withUser } from '../../stories/fixtures';
|
|
7
|
+
import { withToolbarButton } from '../../storybook/withUserMenu';
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof UserAvatar> = {
|
|
10
|
+
title: 'Identity/UserMenu/UserAvatar',
|
|
11
|
+
component: UserAvatar,
|
|
12
|
+
};
|
|
13
|
+
export default meta;
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj<typeof UserAvatar>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where it is actually seen.
|
|
19
|
+
*
|
|
20
|
+
* This add-on's `Toolbar.jsx` puts it on `#toolbar-personal` in place of
|
|
21
|
+
* Volto's camera icon, at 30px on the toolbar's own ground. The stories below
|
|
22
|
+
* show the component; this one shows the component in its place, which is the
|
|
23
|
+
* only one that says whether 30px is enough to tell two people apart.
|
|
24
|
+
*/
|
|
25
|
+
export const OnTheToolbarButton: Story = {
|
|
26
|
+
args: { size: '30px' },
|
|
27
|
+
decorators: [withUser({ portrait: null }), withToolbarButton],
|
|
28
|
+
// The toolbar is fixed and full width; boxing it in the page wrapper would
|
|
29
|
+
// show it somewhere it never is.
|
|
30
|
+
parameters: { fullBleed: true },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The user uploaded a portrait, so it is the portrait. */
|
|
34
|
+
export const WithPortrait: Story = {
|
|
35
|
+
decorators: [withUser({ portrait: PORTRAIT })],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The common case. Most people never upload a portrait, and Volto drew a
|
|
40
|
+
* camera icon for all of them -- the same picture for everybody.
|
|
41
|
+
*/
|
|
42
|
+
export const Initials: Story = {
|
|
43
|
+
decorators: [withUser({ portrait: null })],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Several people at once, which is the only way to see the point of the
|
|
48
|
+
* colour: it is derived from the userid, so the same person is the same
|
|
49
|
+
* colour everywhere and two people in a list are told apart at a glance.
|
|
50
|
+
*
|
|
51
|
+
* Each avatar gets its own store, because the user is read from it rather
|
|
52
|
+
* than passed in as a prop.
|
|
53
|
+
*/
|
|
54
|
+
export const DifferentPeople: Story = {
|
|
55
|
+
render: () => (
|
|
56
|
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
57
|
+
{[
|
|
58
|
+
{ id: 'alice', fullname: 'Alice Liddell' },
|
|
59
|
+
{ id: 'bob', fullname: 'Bob Ross' },
|
|
60
|
+
{ id: 'carol', fullname: 'Carol Danvers' },
|
|
61
|
+
{ id: 'dave', fullname: 'Dave Grohl' },
|
|
62
|
+
{ id: 'ericof', fullname: 'Érico Andrei' },
|
|
63
|
+
].map((who) => (
|
|
64
|
+
<Provider
|
|
65
|
+
key={who.id}
|
|
66
|
+
store={
|
|
67
|
+
{
|
|
68
|
+
getState: () => ({
|
|
69
|
+
userProfile: { ...LOADED, data: { ...USER, ...who } },
|
|
70
|
+
}),
|
|
71
|
+
dispatch: (action: unknown) => action,
|
|
72
|
+
subscribe: () => () => {},
|
|
73
|
+
} as never
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
<UserAvatar size="48px" />
|
|
77
|
+
</Provider>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A middle name must not push the surname out: this is ÉA, not ÉD. */
|
|
84
|
+
export const MiddleName: Story = {
|
|
85
|
+
decorators: [withUser({ id: 'ericof', fullname: 'Érico de Andrei' })],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** One word is the only case that takes two letters from the same word. */
|
|
89
|
+
export const SingleName: Story = {
|
|
90
|
+
decorators: [withUser({ id: 'madonna', fullname: 'Madonna' })],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** No name yet: the userid stands in rather than leaving an empty circle. */
|
|
94
|
+
export const NameNotLoadedYet: Story = {
|
|
95
|
+
decorators: [withUser({ id: 'alice', fullname: null })],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* An anonymous visitor. The component mounts on every route, so this is a
|
|
100
|
+
* state it really renders in, not a hypothetical.
|
|
101
|
+
*/
|
|
102
|
+
export const Anonymous: Story = {
|
|
103
|
+
decorators: [withUser(null)],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The size the toolbar button asks for, beside the size the menu used to. */
|
|
107
|
+
export const Large: Story = {
|
|
108
|
+
args: { size: '96px' },
|
|
109
|
+
decorators: [withUser({ portrait: null })],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A portrait URL that does not resolve falls back to the initials.
|
|
114
|
+
*
|
|
115
|
+
* A deleted portrait, or a stale URL after a rename. The browser's
|
|
116
|
+
* broken-image glyph reads as a bug in the site rather than as a missing
|
|
117
|
+
* photograph, which is what this avoids.
|
|
118
|
+
*/
|
|
119
|
+
export const BrokenPortrait: Story = {
|
|
120
|
+
decorators: [withUser({ portrait: '/no-such-portrait.png' })],
|
|
121
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import UserAvatar from './UserAvatar';
|
|
7
|
+
import { AVATAR_COLORS } from '../../helpers/avatar';
|
|
8
|
+
|
|
9
|
+
function renderAvatar(data: unknown, props = {}) {
|
|
10
|
+
const store = {
|
|
11
|
+
getState: () => ({
|
|
12
|
+
userProfile: { loading: false, loaded: true, error: null, data },
|
|
13
|
+
}),
|
|
14
|
+
dispatch: (action: unknown) => action,
|
|
15
|
+
subscribe: () => () => {},
|
|
16
|
+
};
|
|
17
|
+
return render(
|
|
18
|
+
<Provider store={store as never}>
|
|
19
|
+
<UserAvatar {...props} />
|
|
20
|
+
</Provider>,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('UserAvatar', () => {
|
|
25
|
+
it('renders the portrait when the user has one', () => {
|
|
26
|
+
renderAvatar({
|
|
27
|
+
id: 'alice',
|
|
28
|
+
fullname: 'Alice Liddell',
|
|
29
|
+
portrait: 'http://localhost:8080/Plone/portrait.png',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const img = screen.getByRole('img', { name: /user avatar/i });
|
|
33
|
+
|
|
34
|
+
// Flattened: the store holds the backend URL and the browser has to ask
|
|
35
|
+
// this site for it.
|
|
36
|
+
expect(img.getAttribute('src')).toBe('/portrait.png');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to initials on a colour', () => {
|
|
40
|
+
// Volto draws a camera icon here, which is the same picture for every
|
|
41
|
+
// user without a portrait.
|
|
42
|
+
const { container } = renderAvatar({
|
|
43
|
+
id: 'alice',
|
|
44
|
+
fullname: 'Alice Liddell',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const avatar = container.querySelector('.identity-avatar--initials');
|
|
48
|
+
expect(avatar?.textContent).toBe('AL');
|
|
49
|
+
expect(avatar).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('colours the initials from the palette', () => {
|
|
53
|
+
const { container } = renderAvatar({
|
|
54
|
+
id: 'alice',
|
|
55
|
+
fullname: 'Alice Liddell',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const style = container
|
|
59
|
+
.querySelector('.identity-avatar--initials')
|
|
60
|
+
?.getAttribute('style');
|
|
61
|
+
|
|
62
|
+
// jsdom renders the hex as rgb(), so assert on the palette having been
|
|
63
|
+
// consulted rather than on one exact string.
|
|
64
|
+
expect(style).toContain('background-color');
|
|
65
|
+
expect(AVATAR_COLORS.length).toBeGreaterThan(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('falls back to the userid when there is no name yet', () => {
|
|
69
|
+
const { container } = renderAvatar({ id: 'alice' });
|
|
70
|
+
|
|
71
|
+
expect(
|
|
72
|
+
container.querySelector('.identity-avatar--initials')?.textContent,
|
|
73
|
+
).toBe('AL');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders a plain circle for an anonymous visitor', () => {
|
|
77
|
+
// Mounted on every route, so this is the common case, not an edge one.
|
|
78
|
+
const { container } = renderAvatar(null);
|
|
79
|
+
|
|
80
|
+
const avatar = container.querySelector('.identity-avatar--initials');
|
|
81
|
+
expect(avatar?.textContent).toBe('');
|
|
82
|
+
// Nothing to read: announcing an empty circle as "user avatar" is noise.
|
|
83
|
+
expect(avatar?.getAttribute('aria-hidden')).toBe('true');
|
|
84
|
+
expect(avatar?.getAttribute('role')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('falls back to initials when the portrait fails to load', () => {
|
|
88
|
+
// A deleted portrait, or a stale URL after a rename. The browser's
|
|
89
|
+
// broken-image glyph reads as a bug in the site rather than as a
|
|
90
|
+
// missing photograph.
|
|
91
|
+
const { container } = renderAvatar({
|
|
92
|
+
id: 'alice',
|
|
93
|
+
fullname: 'Alice Liddell',
|
|
94
|
+
portrait: '/gone.png',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
fireEvent.error(screen.getByRole('img', { name: /user avatar/i }));
|
|
98
|
+
|
|
99
|
+
expect(container.querySelector('img')).toBeNull();
|
|
100
|
+
expect(
|
|
101
|
+
container.querySelector('.identity-avatar--initials')?.textContent,
|
|
102
|
+
).toBe('AL');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('accepts extra classes without losing its own', () => {
|
|
106
|
+
const { container } = renderAvatar({ id: 'alice' }, { className: 'mine' });
|
|
107
|
+
|
|
108
|
+
const avatar = container.querySelector('.identity-avatar');
|
|
109
|
+
expect(avatar?.className).toContain('identity-avatar--initials');
|
|
110
|
+
expect(avatar?.className).toContain('mine');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('takes the size it is given', () => {
|
|
114
|
+
const { container } = renderAvatar({ id: 'alice' }, { size: '96px' });
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
container.querySelector('.identity-avatar')?.getAttribute('style'),
|
|
118
|
+
).toContain('96px');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed-in user, as a picture or as their initials.
|
|
3
|
+
*
|
|
4
|
+
* Reads the user this package keeps in the store rather than Volto's
|
|
5
|
+
* `state.users.user`: that one is fetched when the personal-tools menu opens
|
|
6
|
+
* and cleared around it, and this renders in the toolbar, on every page,
|
|
7
|
+
* whether or not the menu was ever opened.
|
|
8
|
+
*
|
|
9
|
+
* It always renders *something*. A user with no portrait gets initials on a
|
|
10
|
+
* colour derived from their userid; a portrait that fails to load falls back
|
|
11
|
+
* to those initials rather than to a broken-image glyph; a user whose name
|
|
12
|
+
* has not arrived gets the plain circle, which is what the space looked like
|
|
13
|
+
* before.
|
|
14
|
+
* @module components/UserMenu/UserAvatar
|
|
15
|
+
*/
|
|
16
|
+
import React, { useMemo, useState } from 'react';
|
|
17
|
+
import { useSelector } from 'react-redux';
|
|
18
|
+
import { useIntl, defineMessages } from 'react-intl';
|
|
19
|
+
import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
|
|
20
|
+
|
|
21
|
+
import { colorFor, initialsFor } from '../../helpers/avatar';
|
|
22
|
+
|
|
23
|
+
import './UserAvatar.scss';
|
|
24
|
+
|
|
25
|
+
const messages = defineMessages({
|
|
26
|
+
avatar: { id: 'user avatar', defaultMessage: 'user avatar' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export interface UserAvatarProps {
|
|
30
|
+
/** Rendered size, as a CSS length. Square. */
|
|
31
|
+
size?: string;
|
|
32
|
+
/** Extra classes, for a caller that places the avatar itself. */
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Join the class names for one variant.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately not `classnames`: that is Volto's dependency, not this
|
|
40
|
+
* package's, and this add-on declares no runtime dependencies at all. One
|
|
41
|
+
* `filter` is a smaller price than the first entry in that list.
|
|
42
|
+
*
|
|
43
|
+
* @param variant The variant class, picture or initials.
|
|
44
|
+
* @param extra Whatever the caller passed.
|
|
45
|
+
* @returns The class attribute.
|
|
46
|
+
*/
|
|
47
|
+
function classes(variant: string, extra?: string): string {
|
|
48
|
+
return ['identity-avatar', variant, extra].filter(Boolean).join(' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The user fields this component reads. */
|
|
52
|
+
interface AvatarUser {
|
|
53
|
+
id?: string | null;
|
|
54
|
+
fullname?: string | null;
|
|
55
|
+
portrait?: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const UserAvatar: React.FC<UserAvatarProps> = ({
|
|
59
|
+
size = '30px',
|
|
60
|
+
className,
|
|
61
|
+
}) => {
|
|
62
|
+
const intl = useIntl();
|
|
63
|
+
const user = useSelector(
|
|
64
|
+
(state: any) => state.userProfile?.data,
|
|
65
|
+
) as AvatarUser | null;
|
|
66
|
+
// Keyed by URL so that signing in as somebody else, or uploading a new
|
|
67
|
+
// portrait, gets a fresh attempt rather than inheriting the last failure.
|
|
68
|
+
const [failedPortrait, setFailedPortrait] = useState<string | null>(null);
|
|
69
|
+
|
|
70
|
+
const portrait = user?.portrait ?? null;
|
|
71
|
+
const label = intl.formatMessage(messages.avatar);
|
|
72
|
+
|
|
73
|
+
// Both walk the name or the userid character by character. Cheap, but this
|
|
74
|
+
// renders on every page in a toolbar that re-renders often.
|
|
75
|
+
const initials = useMemo(
|
|
76
|
+
() => initialsFor(user?.fullname || user?.id),
|
|
77
|
+
[user?.fullname, user?.id],
|
|
78
|
+
);
|
|
79
|
+
const background = useMemo(() => colorFor(user?.id), [user?.id]);
|
|
80
|
+
|
|
81
|
+
if (portrait && portrait !== failedPortrait) {
|
|
82
|
+
return (
|
|
83
|
+
<img
|
|
84
|
+
className={classes('identity-avatar--picture', className)}
|
|
85
|
+
style={{ width: size, height: size }}
|
|
86
|
+
src={flattenToAppURL(portrait)}
|
|
87
|
+
alt={label}
|
|
88
|
+
// A portrait that 404s -- deleted, or a stale URL after a rename --
|
|
89
|
+
// would otherwise render as the browser's broken-image glyph, which
|
|
90
|
+
// looks like a bug in the site rather than a missing photograph.
|
|
91
|
+
onError={() => setFailedPortrait(portrait)}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<span
|
|
98
|
+
className={classes('identity-avatar--initials', className)}
|
|
99
|
+
style={{
|
|
100
|
+
width: size,
|
|
101
|
+
height: size,
|
|
102
|
+
// Inline because the colour is per user: a class per palette entry
|
|
103
|
+
// would be ten rules that exist only to avoid this attribute.
|
|
104
|
+
backgroundColor: background,
|
|
105
|
+
// The glyph should fill the circle at whatever size it is asked for.
|
|
106
|
+
fontSize: `calc(${size} / 2.4)`,
|
|
107
|
+
}}
|
|
108
|
+
// Decorative when there are no initials to read: an empty circle
|
|
109
|
+
// announced as "user avatar" is noise to a screen reader.
|
|
110
|
+
role={initials ? 'img' : undefined}
|
|
111
|
+
aria-label={initials ? label : undefined}
|
|
112
|
+
aria-hidden={initials ? undefined : true}
|
|
113
|
+
>
|
|
114
|
+
{initials}
|
|
115
|
+
</span>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default UserAvatar;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Pluggable,
|
|
6
|
+
PluggablesProvider,
|
|
7
|
+
} from '@plone/volto/components/manage/Pluggable';
|
|
8
|
+
import IdentitiesMenuItem from './IdentitiesMenuItem';
|
|
9
|
+
import ProfileMenuItem from './ProfileMenuItem';
|
|
10
|
+
import {
|
|
11
|
+
PersonalInformationMenuItem,
|
|
12
|
+
PreferencesMenuItem,
|
|
13
|
+
SiteSetupMenuItem,
|
|
14
|
+
} from './UserMenuPlugs';
|
|
15
|
+
import { LOADED, USER, withStore } from '../../stories/fixtures';
|
|
16
|
+
import { withPersonalTools } from '../../storybook/withUserMenu';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Volto's own menu entries, re-registered as plugs.
|
|
20
|
+
*
|
|
21
|
+
* Each renders nothing on its own -- a plug registers a renderer and returns
|
|
22
|
+
* null -- so the stories mount the pluggable that consumes them. What is
|
|
23
|
+
* worth looking at here is the *assembled* list: which entries appear, and
|
|
24
|
+
* in what order.
|
|
25
|
+
*/
|
|
26
|
+
const meta: Meta<typeof PreferencesMenuItem> = {
|
|
27
|
+
title: 'Identity/UserMenu/UserMenuPlugs',
|
|
28
|
+
component: PreferencesMenuItem,
|
|
29
|
+
decorators: [withPersonalTools],
|
|
30
|
+
parameters: { fullBleed: true },
|
|
31
|
+
};
|
|
32
|
+
export default meta;
|
|
33
|
+
|
|
34
|
+
type Story = StoryObj<typeof PreferencesMenuItem>;
|
|
35
|
+
|
|
36
|
+
const withState = (user: Record<string, unknown>, actions: unknown[] = []) =>
|
|
37
|
+
withStore({
|
|
38
|
+
userProfile: { ...LOADED, data: { ...USER, ...user } },
|
|
39
|
+
actions: { actions: { user: actions } },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every entry the add-on registers, in the menu's own markup.
|
|
44
|
+
*
|
|
45
|
+
* The markup comes from `withPersonalTools` rather than from here: it is
|
|
46
|
+
* Volto's, it has to sit inside `#toolbar` to be styled at all, and five
|
|
47
|
+
* copies of it across these stories is five things to fix when Volto moves
|
|
48
|
+
* one of them.
|
|
49
|
+
*/
|
|
50
|
+
const menu = () => (
|
|
51
|
+
<PluggablesProvider>
|
|
52
|
+
<PersonalInformationMenuItem />
|
|
53
|
+
<PreferencesMenuItem />
|
|
54
|
+
<SiteSetupMenuItem />
|
|
55
|
+
<IdentitiesMenuItem />
|
|
56
|
+
<ProfileMenuItem />
|
|
57
|
+
<ul>
|
|
58
|
+
<Pluggable
|
|
59
|
+
name="toolbar-user-menu"
|
|
60
|
+
params={{ loadComponent: () => {} }}
|
|
61
|
+
/>
|
|
62
|
+
</ul>
|
|
63
|
+
</PluggablesProvider>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A member with a Profile of their own.
|
|
68
|
+
*
|
|
69
|
+
* "My profile" holds the first slot instead of Volto's Profile link, and
|
|
70
|
+
* "Sign-in methods" comes straight after Preferences because choosing how
|
|
71
|
+
* you get in is one.
|
|
72
|
+
*/
|
|
73
|
+
export const Member: Story = { render: menu, decorators: [withState({})] };
|
|
74
|
+
|
|
75
|
+
/** A manager. Site Setup is last: it is about the site, not the person. */
|
|
76
|
+
export const Manager: Story = {
|
|
77
|
+
render: menu,
|
|
78
|
+
decorators: [withState({}, [{ id: 'plone_setup' }])],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A member with no Profile.
|
|
83
|
+
*
|
|
84
|
+
* Volto's Profile link takes its slot back, leading to the member form at
|
|
85
|
+
* `/personal-information`.
|
|
86
|
+
*/
|
|
87
|
+
export const WithoutTheProfileLayer: Story = {
|
|
88
|
+
render: menu,
|
|
89
|
+
decorators: [withState({ profile_url: null })],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A user who has a Profile but is not stored as one.
|
|
94
|
+
*
|
|
95
|
+
* The same menu as above, and for the same reason: the member form is where
|
|
96
|
+
* a `source_users` account's fields are edited, whatever else exists.
|
|
97
|
+
*/
|
|
98
|
+
export const AProfileTheyAreNotStoredIn: Story = {
|
|
99
|
+
render: menu,
|
|
100
|
+
decorators: [withState({ source: 'source_users' })],
|
|
101
|
+
};
|