@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.
Files changed (191) hide show
  1. package/.changelog.draft +261 -0
  2. package/.release-it.json +34 -0
  3. package/CHANGELOG.md +269 -0
  4. package/LICENSE +21 -0
  5. package/README.md +228 -0
  6. package/babel.config.js +17 -0
  7. package/locales/de/LC_MESSAGES/volto.po +1162 -0
  8. package/locales/en/LC_MESSAGES/volto.po +1162 -0
  9. package/locales/es/LC_MESSAGES/volto.po +1169 -0
  10. package/locales/pt_BR/LC_MESSAGES/volto.po +1167 -0
  11. package/locales/volto.pot +1164 -0
  12. package/news/.changelog_template.jinja +15 -0
  13. package/news/.gitkeep +0 -0
  14. package/package.json +66 -0
  15. package/public/.gitkeep +0 -0
  16. package/src/actions/actions.test.ts +226 -0
  17. package/src/actions/index.ts +530 -0
  18. package/src/components/.gitkeep +0 -0
  19. package/src/components/Applications/Applications.tsx +169 -0
  20. package/src/components/Applications/ApplicationsPanel.scss +59 -0
  21. package/src/components/Applications/ApplicationsPanel.stories.tsx +82 -0
  22. package/src/components/Applications/ApplicationsPanel.test.tsx +235 -0
  23. package/src/components/Applications/ApplicationsPanel.tsx +342 -0
  24. package/src/components/Callback/Callback.scss +17 -0
  25. package/src/components/Callback/Callback.stories.tsx +43 -0
  26. package/src/components/Callback/Callback.test.tsx +262 -0
  27. package/src/components/Callback/Callback.tsx +170 -0
  28. package/src/components/Consent/Consent.test.tsx +75 -0
  29. package/src/components/Consent/Consent.tsx +98 -0
  30. package/src/components/Consent/ConsentPanel.scss +37 -0
  31. package/src/components/Consent/ConsentPanel.stories.tsx +80 -0
  32. package/src/components/Consent/ConsentPanel.test.tsx +138 -0
  33. package/src/components/Consent/ConsentPanel.tsx +162 -0
  34. package/src/components/ControlPanel/ClientsControlPanel.stories.tsx +38 -0
  35. package/src/components/ControlPanel/ClientsControlPanel.tsx +321 -0
  36. package/src/components/ControlPanel/ClientsPanel.scss +21 -0
  37. package/src/components/ControlPanel/ClientsPanel.stories.tsx +71 -0
  38. package/src/components/ControlPanel/ClientsPanel.test.tsx +267 -0
  39. package/src/components/ControlPanel/ClientsPanel.tsx +386 -0
  40. package/src/components/ControlPanel/ConfirmModal.stories.tsx +35 -0
  41. package/src/components/ControlPanel/ConfirmModal.test.tsx +66 -0
  42. package/src/components/ControlPanel/ConfirmModal.tsx +68 -0
  43. package/src/components/ControlPanel/ProviderForm.stories.tsx +107 -0
  44. package/src/components/ControlPanel/ProvidersControlPanel.scss +26 -0
  45. package/src/components/ControlPanel/ProvidersControlPanel.stories.tsx +88 -0
  46. package/src/components/ControlPanel/ProvidersControlPanel.tsx +600 -0
  47. package/src/components/ControlPanel/RenderUsers.stories.tsx +112 -0
  48. package/src/components/ControlPanel/RenderUsers.test.tsx +145 -0
  49. package/src/components/ControlPanel/RenderUsers.tsx +263 -0
  50. package/src/components/ControlPanel/SecretReveal.scss +32 -0
  51. package/src/components/ControlPanel/SecretReveal.stories.tsx +33 -0
  52. package/src/components/ControlPanel/SecretReveal.test.tsx +101 -0
  53. package/src/components/ControlPanel/SecretReveal.tsx +105 -0
  54. package/src/components/ControlPanel/UserAccount.stories.tsx +69 -0
  55. package/src/components/ControlPanel/UserAccount.test.tsx +117 -0
  56. package/src/components/ControlPanel/UserAccount.tsx +125 -0
  57. package/src/components/ControlPanel/UserAccountPanel.scss +38 -0
  58. package/src/components/ControlPanel/UserAccountPanel.stories.tsx +77 -0
  59. package/src/components/ControlPanel/UserAccountPanel.test.tsx +173 -0
  60. package/src/components/ControlPanel/UserAccountPanel.tsx +273 -0
  61. package/src/components/FirstLogin/FirstLogin.scss +12 -0
  62. package/src/components/FirstLogin/FirstLogin.stories.tsx +50 -0
  63. package/src/components/FirstLogin/FirstLogin.tsx +94 -0
  64. package/src/components/Identities/Identities.stories.tsx +51 -0
  65. package/src/components/Identities/Identities.test.tsx +134 -0
  66. package/src/components/Identities/Identities.tsx +217 -0
  67. package/src/components/Identities/IdentitiesList.scss +49 -0
  68. package/src/components/Identities/IdentitiesList.stories.tsx +71 -0
  69. package/src/components/Identities/IdentitiesList.test.tsx +218 -0
  70. package/src/components/Identities/IdentitiesList.tsx +207 -0
  71. package/src/components/Identities/ProfileEmails.scss +62 -0
  72. package/src/components/Identities/ProfileEmails.stories.tsx +75 -0
  73. package/src/components/Identities/ProfileEmails.test.tsx +132 -0
  74. package/src/components/Identities/ProfileEmails.tsx +217 -0
  75. package/src/components/Login/Login.stories.tsx +63 -0
  76. package/src/components/Login/Login.test.tsx +189 -0
  77. package/src/components/Login/Login.tsx +163 -0
  78. package/src/components/Login/LoginForm.scss +39 -0
  79. package/src/components/Login/LoginForm.stories.tsx +112 -0
  80. package/src/components/Login/LoginForm.test.tsx +483 -0
  81. package/src/components/Login/LoginForm.tsx +321 -0
  82. package/src/components/Login/LoginOverlay.scss +44 -0
  83. package/src/components/Login/LoginOverlay.stories.tsx +73 -0
  84. package/src/components/Login/LoginOverlay.test.tsx +98 -0
  85. package/src/components/Login/LoginOverlay.tsx +107 -0
  86. package/src/components/Login/LoginPanel.scss +50 -0
  87. package/src/components/Login/LoginPanel.stories.tsx +40 -0
  88. package/src/components/Login/LoginPanel.tsx +60 -0
  89. package/src/components/Login/MagicLinkForm.scss +25 -0
  90. package/src/components/Login/MagicLinkForm.stories.tsx +27 -0
  91. package/src/components/Login/MagicLinkForm.tsx +133 -0
  92. package/src/components/Login/PasswordForm.scss +82 -0
  93. package/src/components/Login/PasswordForm.stories.tsx +26 -0
  94. package/src/components/Login/PasswordForm.tsx +159 -0
  95. package/src/components/Login/ProviderButton.scss +103 -0
  96. package/src/components/Login/ProviderButton.stories.tsx +74 -0
  97. package/src/components/Login/ProviderButton.test.tsx +99 -0
  98. package/src/components/Login/ProviderButton.tsx +97 -0
  99. package/src/components/ProfileGate/ProfileGate.stories.tsx +118 -0
  100. package/src/components/ProfileGate/ProfileGate.test.tsx +370 -0
  101. package/src/components/ProfileGate/ProfileGate.tsx +172 -0
  102. package/src/components/Toolbar/PersonalTools.stories.tsx +111 -0
  103. package/src/components/Toolbar/PersonalTools.test.tsx +165 -0
  104. package/src/components/Toolbar/PersonalTools.tsx +177 -0
  105. package/src/components/Toolbar/Toolbar.jsx +766 -0
  106. package/src/components/UserMenu/ApplicationsMenuItem.stories.tsx +62 -0
  107. package/src/components/UserMenu/ApplicationsMenuItem.test.tsx +109 -0
  108. package/src/components/UserMenu/ApplicationsMenuItem.tsx +82 -0
  109. package/src/components/UserMenu/IdentitiesMenuItem.stories.tsx +35 -0
  110. package/src/components/UserMenu/IdentitiesMenuItem.test.tsx +103 -0
  111. package/src/components/UserMenu/IdentitiesMenuItem.tsx +34 -0
  112. package/src/components/UserMenu/MenuItem.stories.tsx +32 -0
  113. package/src/components/UserMenu/MenuItem.tsx +48 -0
  114. package/src/components/UserMenu/ProfileMenuItem.stories.tsx +76 -0
  115. package/src/components/UserMenu/ProfileMenuItem.test.tsx +90 -0
  116. package/src/components/UserMenu/ProfileMenuItem.tsx +60 -0
  117. package/src/components/UserMenu/UserAvatar.scss +23 -0
  118. package/src/components/UserMenu/UserAvatar.stories.tsx +121 -0
  119. package/src/components/UserMenu/UserAvatar.test.tsx +120 -0
  120. package/src/components/UserMenu/UserAvatar.tsx +119 -0
  121. package/src/components/UserMenu/UserMenuPlugs.stories.tsx +101 -0
  122. package/src/components/UserMenu/UserMenuPlugs.test.tsx +202 -0
  123. package/src/components/UserMenu/UserMenuPlugs.tsx +142 -0
  124. package/src/components/UserMenu/UserProfileLoader.test.tsx +211 -0
  125. package/src/components/UserMenu/UserProfileLoader.tsx +54 -0
  126. package/src/components/Views/GroupView.scss +36 -0
  127. package/src/components/Views/GroupView.stories.tsx +98 -0
  128. package/src/components/Views/GroupView.test.tsx +137 -0
  129. package/src/components/Views/GroupView.tsx +178 -0
  130. package/src/components/Views/ProfileView.scss +27 -0
  131. package/src/components/Views/ProfileView.stories.tsx +34 -0
  132. package/src/components/Views/ProfileView.test.tsx +82 -0
  133. package/src/components/Views/ProfileView.tsx +100 -0
  134. package/src/components/Widgets/ProviderIconWidget.scss +21 -0
  135. package/src/components/Widgets/ProviderIconWidget.stories.tsx +30 -0
  136. package/src/components/Widgets/ProviderIconWidget.test.tsx +55 -0
  137. package/src/components/Widgets/ProviderIconWidget.tsx +143 -0
  138. package/src/config/menu.ts +35 -0
  139. package/src/config/reducers.ts +7 -0
  140. package/src/config/routes.test.ts +177 -0
  141. package/src/config/routes.ts +143 -0
  142. package/src/config/settings.test.ts +34 -0
  143. package/src/config/settings.ts +72 -0
  144. package/src/config/views.ts +30 -0
  145. package/src/config/widgets.ts +25 -0
  146. package/src/constants/ActionTypes.ts +42 -0
  147. package/src/constants/vocabularies.ts +20 -0
  148. package/src/customizations/volto/components/manage/Controlpanels/Users/RenderUsers.tsx +23 -0
  149. package/src/customizations/volto/components/manage/Toolbar/PersonalTools.tsx +21 -0
  150. package/src/customizations/volto/components/manage/Toolbar/Toolbar.jsx +21 -0
  151. package/src/helpers/avatar.test.ts +79 -0
  152. package/src/helpers/avatar.ts +85 -0
  153. package/src/helpers/callback.test.ts +60 -0
  154. package/src/helpers/callback.ts +71 -0
  155. package/src/helpers/clientSchema.test.ts +182 -0
  156. package/src/helpers/clientSchema.ts +181 -0
  157. package/src/helpers/firstLogin.test.ts +90 -0
  158. package/src/helpers/firstLogin.ts +76 -0
  159. package/src/helpers/groupmap.test.ts +81 -0
  160. package/src/helpers/groupmap.ts +50 -0
  161. package/src/helpers/identities.ts +22 -0
  162. package/src/helpers/navigate.test.ts +132 -0
  163. package/src/helpers/navigate.ts +126 -0
  164. package/src/helpers/profileGate.test.ts +150 -0
  165. package/src/helpers/profileGate.ts +191 -0
  166. package/src/helpers/profileSource.ts +41 -0
  167. package/src/helpers/propertymap.test.ts +71 -0
  168. package/src/helpers/propertymap.ts +43 -0
  169. package/src/helpers/providerSchema.test.ts +410 -0
  170. package/src/helpers/providerSchema.ts +362 -0
  171. package/src/helpers/returnUrl.test.ts +42 -0
  172. package/src/helpers/returnUrl.ts +41 -0
  173. package/src/helpers/rowmap.ts +110 -0
  174. package/src/helpers/showPloneLogin.test.ts +78 -0
  175. package/src/helpers/showPloneLogin.ts +55 -0
  176. package/src/helpers/token.test.ts +62 -0
  177. package/src/helpers/token.ts +71 -0
  178. package/src/index.ts +22 -0
  179. package/src/reducers/index.ts +369 -0
  180. package/src/reducers/reducers.test.ts +369 -0
  181. package/src/stories/fixtures.tsx +640 -0
  182. package/src/storybook/withPage.test.tsx +90 -0
  183. package/src/storybook/withPage.tsx +110 -0
  184. package/src/storybook/withUserMenu.test.tsx +78 -0
  185. package/src/storybook/withUserMenu.tsx +136 -0
  186. package/src/styles.css +262 -0
  187. package/src/testing.tsx +86 -0
  188. package/src/types.ts +441 -0
  189. package/towncrier.toml +38 -0
  190. package/tsconfig.json +22 -0
  191. 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
+ };