@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,105 @@
1
+ /**
2
+ * The one moment a client secret exists.
3
+ *
4
+ * The server stores a scrypt hash and cannot read the secret back, so this is
5
+ * shown once — at registration or rotation — and never again. That makes it
6
+ * the one place in this add-on where dismissing a panel loses something
7
+ * irrecoverable, so it says so plainly and does not close itself.
8
+ * @module components/ControlPanel/SecretReveal
9
+ */
10
+ import React, { useState } from 'react';
11
+ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
12
+
13
+ import type { OAuthClient } from '../../types';
14
+
15
+ import './SecretReveal.scss';
16
+
17
+ const messages = defineMessages({
18
+ heading: {
19
+ id: 'Secret for {clientId}',
20
+ defaultMessage: 'Secret for {clientId}',
21
+ },
22
+ notice: {
23
+ id: 'This is the only time this secret is shown.',
24
+ defaultMessage:
25
+ 'This is the only time this secret is shown. It is stored hashed and ' +
26
+ 'cannot be read back; if it is lost, rotate it.',
27
+ },
28
+ secretFor: {
29
+ id: 'Client secret for {clientId}',
30
+ defaultMessage: 'Client secret for {clientId}',
31
+ },
32
+ copy: { id: 'Copy', defaultMessage: 'Copy' },
33
+ copied: { id: 'Copied', defaultMessage: 'Copied' },
34
+ dismiss: { id: 'I have saved it', defaultMessage: 'I have saved it' },
35
+ });
36
+
37
+ interface SecretRevealProps {
38
+ client: OAuthClient;
39
+ onDismiss: () => void;
40
+ }
41
+
42
+ const SecretReveal: React.FC<SecretRevealProps> = ({ client, onDismiss }) => {
43
+ const intl = useIntl();
44
+ const [copied, setCopied] = useState(false);
45
+
46
+ const copy = async () => {
47
+ try {
48
+ await navigator.clipboard.writeText(client.secret ?? '');
49
+ setCopied(true);
50
+ } catch {
51
+ // A browser that refuses clipboard access is not an error worth
52
+ // reporting: the secret is on screen and selectable, which is the
53
+ // fallback anyway.
54
+ setCopied(false);
55
+ }
56
+ };
57
+
58
+ return (
59
+ <section
60
+ className="identity-secret"
61
+ role="alertdialog"
62
+ aria-labelledby="identity-secret-heading"
63
+ >
64
+ <h3 id="identity-secret-heading">
65
+ <FormattedMessage
66
+ {...messages.heading}
67
+ values={{ clientId: <code>{client.client_id}</code> }}
68
+ />
69
+ </h3>
70
+
71
+ {/* The server's own notice when it sent one: it knows what it did --
72
+ minted or rotated -- and this does not. */}
73
+ <p className="identity-secret__notice">
74
+ {client.notice ?? intl.formatMessage(messages.notice)}
75
+ </p>
76
+
77
+ <div className="identity-secret__value">
78
+ {/* Read-only rather than plain text so it can be selected and copied
79
+ with the keyboard by somebody not using a mouse. */}
80
+ <input
81
+ type="text"
82
+ readOnly
83
+ value={client.secret ?? ''}
84
+ aria-label={intl.formatMessage(messages.secretFor, {
85
+ clientId: client.client_id,
86
+ })}
87
+ onFocus={(event) => event.currentTarget.select()}
88
+ />
89
+ <button type="button" className="identity-button" onClick={copy}>
90
+ {intl.formatMessage(copied ? messages.copied : messages.copy)}
91
+ </button>
92
+ </div>
93
+
94
+ <button
95
+ type="button"
96
+ className="identity-button identity-secret__dismiss"
97
+ onClick={onDismiss}
98
+ >
99
+ {intl.formatMessage(messages.dismiss)}
100
+ </button>
101
+ </section>
102
+ );
103
+ };
104
+
105
+ export default SecretReveal;
@@ -0,0 +1,69 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { MemoryRouter, Route } from 'react-router-dom';
3
+ import React from 'react';
4
+
5
+ import UserAccount from './UserAccount';
6
+ import { USER_ACCOUNT_PATH } from '../../config/routes';
7
+ import {
8
+ FAILED,
9
+ LOADED,
10
+ USER_ACCOUNT,
11
+ withStore,
12
+ } from '../../stories/fixtures';
13
+
14
+ /**
15
+ * The page a Manager reaches from the users control panel.
16
+ *
17
+ * Rendered at its own route rather than mounted bare: the page reads the
18
+ * userid off the path, which is the whole reason it is a route and not the
19
+ * modal it used to be.
20
+ */
21
+ const atRoute = (Story: () => React.ReactNode) => (
22
+ <MemoryRouter initialEntries={['/controlpanel/users/erico/account']}>
23
+ <Route path={USER_ACCOUNT_PATH} render={() => <>{Story()}</>} />
24
+ </MemoryRouter>
25
+ );
26
+
27
+ const meta: Meta<typeof UserAccount> = {
28
+ title: 'Identity/ControlPanel/UserAccount',
29
+ component: UserAccount,
30
+ decorators: [atRoute],
31
+ };
32
+ export default meta;
33
+
34
+ type Story = StoryObj<typeof UserAccount>;
35
+
36
+ export const Default: Story = {
37
+ decorators: [withStore({ userAccount: { ...LOADED, data: USER_ACCOUNT } })],
38
+ };
39
+
40
+ export const Loading: Story = {
41
+ decorators: [
42
+ withStore({
43
+ userAccount: { loading: true, loaded: false, error: null, data: null },
44
+ }),
45
+ ],
46
+ };
47
+
48
+ /**
49
+ * Arrived from another user's page, before this one's answer is in.
50
+ *
51
+ * One slice in the store holds one account, so the previous user is still in
52
+ * it -- and rendering their identities under this person's name is the one
53
+ * mistake this page must not make.
54
+ */
55
+ export const AnotherUserStillInTheStore: Story = {
56
+ decorators: [
57
+ withStore({
58
+ userAccount: {
59
+ ...LOADED,
60
+ data: { ...USER_ACCOUNT, userid: 'somebody-else' },
61
+ },
62
+ }),
63
+ ],
64
+ };
65
+
66
+ /** A Manager without the permission, or a userid that is not there. */
67
+ export const Refused: Story = {
68
+ decorators: [withStore({ userAccount: { ...FAILED, data: null } })],
69
+ };
@@ -0,0 +1,117 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { render, screen } from '../../testing';
3
+ import { MemoryRouter, Route } from 'react-router-dom';
4
+ import { Provider } from 'react-redux';
5
+ import React from 'react';
6
+
7
+ import UserAccount from './UserAccount';
8
+ import { USER_ACCOUNT_PATH } from '../../config/routes';
9
+ import { USER_ACCOUNT } from '../../stories/fixtures';
10
+
11
+ // Chrome around the panel rather than anything this test is about, and it
12
+ // reads store slices this page does not own. `vi.mock` is hoisted above the
13
+ // imports, so it takes effect despite sitting below them.
14
+ vi.mock('@plone/volto/components/manage/Toolbar/Toolbar', () => ({
15
+ default: () => null,
16
+ }));
17
+
18
+ function renderAt(userid: string, userAccount: any) {
19
+ const state = { userAccount };
20
+ const dispatched: any[] = [];
21
+ const store = {
22
+ getState: () => state,
23
+ dispatch: (action: any) => {
24
+ dispatched.push(action);
25
+ return action;
26
+ },
27
+ subscribe: () => () => {},
28
+ };
29
+ render(
30
+ <Provider store={store as any}>
31
+ <MemoryRouter initialEntries={[`/controlpanel/users/${userid}/account`]}>
32
+ <Route path={USER_ACCOUNT_PATH} component={UserAccount} />
33
+ </MemoryRouter>
34
+ </Provider>,
35
+ );
36
+ return { dispatched };
37
+ }
38
+
39
+ const LOADED = { loading: false, loaded: true, error: null };
40
+
41
+ describe('UserAccount', () => {
42
+ beforeEach(() => {
43
+ const toolbar = document.createElement('div');
44
+ toolbar.id = 'toolbar';
45
+ document.body.appendChild(toolbar);
46
+ });
47
+
48
+ afterEach(() => {
49
+ document.getElementById('toolbar')?.remove();
50
+ });
51
+
52
+ it('asks for the account named in the route', () => {
53
+ // The whole point of the route: the page knows who it is about without a
54
+ // row having fetched them first, which is what makes it linkable.
55
+ const { dispatched } = renderAt('erico', {
56
+ ...LOADED,
57
+ data: USER_ACCOUNT,
58
+ });
59
+
60
+ const paths = dispatched
61
+ .filter((action) => action?.request?.path)
62
+ .map((action) => action.request.path);
63
+ expect(paths.some((path: string) => path.includes('erico'))).toBe(true);
64
+ });
65
+
66
+ it('escapes the userid exactly once on the way back out', () => {
67
+ // A userid is whatever the provider minted, and this one has to survive
68
+ // the round trip through a path segment. react-router half decodes the
69
+ // parameter -- `%20` becomes a space, `%2F` does not -- so handing it
70
+ // straight to an action that escapes what it is given encodes the `%` a
71
+ // second time and asks for a userid nobody has.
72
+ const { dispatched } = renderAt(encodeURIComponent('a b/c'), {
73
+ ...LOADED,
74
+ data: null,
75
+ });
76
+
77
+ const paths = dispatched
78
+ .filter((action) => action?.request?.path)
79
+ .map((action) => action.request.path);
80
+ expect(paths).toContain(`/@user-account/${encodeURIComponent('a b/c')}`);
81
+ expect(paths.some((path: string) => path.includes('%25'))).toBe(false);
82
+ });
83
+
84
+ it('shows the account it was given', () => {
85
+ renderAt('erico', { ...LOADED, data: USER_ACCOUNT });
86
+
87
+ expect(screen.getByRole('tab', { name: 'Sign-in methods' })).toBeTruthy();
88
+ expect(screen.getByText('GitHub')).toBeTruthy();
89
+ });
90
+
91
+ it('names the person the page is about', () => {
92
+ renderAt('erico', { ...LOADED, data: USER_ACCOUNT });
93
+
94
+ expect(document.body.textContent).toContain('Érico Andrei');
95
+ });
96
+
97
+ it('waits rather than showing the last user that was opened', () => {
98
+ // One slice in the store holds one account. Arriving from another user's
99
+ // page leaves their answer in it, and rendering that under this person's
100
+ // name is the one mistake this page must not make.
101
+ renderAt('someone-else', { ...LOADED, data: USER_ACCOUNT });
102
+
103
+ expect(screen.queryByText('GitHub')).toBeNull();
104
+ expect(screen.getByRole('status')).toBeTruthy();
105
+ });
106
+
107
+ it('reports a refusal rather than waiting for ever', () => {
108
+ renderAt('erico', {
109
+ loading: false,
110
+ loaded: false,
111
+ error: { status: 403 },
112
+ data: null,
113
+ });
114
+
115
+ expect(screen.getByRole('alert')).toBeTruthy();
116
+ });
117
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * One user's account, as a page in the control panel.
3
+ *
4
+ * The store and the routing around `UserAccountPanel`, which is the same
5
+ * split every other page here uses: the panel renders what it is given, this
6
+ * decides who it is about and asks for them.
7
+ *
8
+ * The userid comes off the route rather than from a row's props, which is the
9
+ * whole reason this exists. As a modal the panel could only ever be opened
10
+ * from the row that fetched it -- so it could not be linked to, bookmarked,
11
+ * opened in a new tab or reached with the back button, and an administrator
12
+ * comparing two accounts had to close one to open the other.
13
+ * @module components/ControlPanel/UserAccount
14
+ */
15
+ import React, { useEffect } from 'react';
16
+ import { useDispatch, useSelector } from 'react-redux';
17
+ import { Link, useLocation, useParams } from 'react-router-dom';
18
+ import { createPortal } from 'react-dom';
19
+ import { Container, Segment } from 'semantic-ui-react';
20
+ import { defineMessages, useIntl } from 'react-intl';
21
+
22
+ import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
23
+ import { useClient } from '@plone/volto/hooks/client/useClient';
24
+ import Icon from '@plone/volto/components/theme/Icon/Icon';
25
+ import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
26
+ import backSVG from '@plone/volto/icons/back.svg';
27
+
28
+ import { getUserAccount } from '../../actions';
29
+ import UserAccountPanel from './UserAccountPanel';
30
+
31
+ /**
32
+ * The userid a route match carries, as the userid it names.
33
+ *
34
+ * `useParams` hands back a segment react-router has only half decoded: it
35
+ * turns `%20` into a space but leaves `%2F` alone, since a decoded slash
36
+ * would no longer be one segment. `getUserAccount` escapes what it is given,
37
+ * so passing that through unchanged encodes the `%` a second time and asks
38
+ * the backend for a userid nobody has.
39
+ *
40
+ * @param value The raw route parameter.
41
+ * @returns The userid, or the value unchanged when it is not valid escaping
42
+ * -- a userid may legitimately contain a bare `%`, and refusing to render
43
+ * the page over it would be worse than asking for it verbatim.
44
+ */
45
+ export function decodeUserid(value: string): string {
46
+ try {
47
+ return decodeURIComponent(value);
48
+ } catch {
49
+ return value;
50
+ }
51
+ }
52
+
53
+ const messages = defineMessages({
54
+ title: { id: 'Account', defaultMessage: 'Account' },
55
+ back: { id: 'Back', defaultMessage: 'Back' },
56
+ });
57
+
58
+ /** Where the listing this page is reached from lives. */
59
+ const USERS_CONTROLPANEL = '/controlpanel/users';
60
+
61
+ const UserAccount: React.FC = () => {
62
+ const intl = useIntl();
63
+ const dispatch = useDispatch();
64
+ const isClient = useClient();
65
+ const { pathname } = useLocation();
66
+ const params = useParams<{ userid: string }>();
67
+ const userid = params.userid ? decodeUserid(params.userid) : params.userid;
68
+
69
+ const request = useSelector((state: any) => state.userAccount);
70
+
71
+ useEffect(() => {
72
+ if (userid) {
73
+ (dispatch as any)(getUserAccount(userid));
74
+ }
75
+ }, [dispatch, userid]);
76
+
77
+ // The slice holds one account at a time, and arriving here from another
78
+ // user's page means last user's answer is still in it. Rendering that under
79
+ // this person's name is the one mistake this page must not make -- so an
80
+ // answer about somebody else is treated as no answer yet.
81
+ const account = request?.data?.userid === userid ? request.data : null;
82
+ const loading = Boolean(request?.loading) || (!account && !request?.error);
83
+
84
+ // The account carries a name; until it arrives the userid is what we have,
85
+ // and it is what the administrator clicked.
86
+ const heading = account?.fullname || userid;
87
+
88
+ return (
89
+ // The chrome Volto's own control-panel pages have: a centring container,
90
+ // a titled panel, and the toolbar with a way back to the listing.
91
+ <Container id="page-user-account">
92
+ <Helmet title={`${intl.formatMessage(messages.title)} — ${heading}`} />
93
+ <Segment.Group raised>
94
+ <Segment className="primary">{heading}</Segment>
95
+ <Segment>
96
+ <UserAccountPanel
97
+ account={account}
98
+ loading={loading}
99
+ error={request?.error}
100
+ />
101
+ </Segment>
102
+ </Segment.Group>
103
+ {isClient &&
104
+ createPortal(
105
+ <Toolbar
106
+ pathname={pathname}
107
+ hideDefaultViewButtons
108
+ inner={
109
+ <Link to={USERS_CONTROLPANEL} className="item">
110
+ <Icon
111
+ name={backSVG}
112
+ className="contents circled"
113
+ size="30px"
114
+ title={intl.formatMessage(messages.back)}
115
+ />
116
+ </Link>
117
+ }
118
+ />,
119
+ document.getElementById('toolbar') as HTMLElement,
120
+ )}
121
+ </Container>
122
+ );
123
+ };
124
+
125
+ export default UserAccount;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * One account's sign-in methods, addresses and recent activity.
3
+ *
4
+ * Three lists with the same rhythm, so an account with one provider and one
5
+ * with four read as the same panel.
6
+ */
7
+ .identity-account {
8
+ h3 {
9
+ margin: var(--identity-gap) 0 4px;
10
+ font-size: 1.05em;
11
+ }
12
+ }
13
+
14
+ .identity-account__list {
15
+ padding: 0;
16
+ margin: 0;
17
+ list-style: none;
18
+
19
+ li {
20
+ display: flex;
21
+ flex-wrap: wrap;
22
+ align-items: baseline;
23
+ padding: 6px 0;
24
+ border-bottom: var(--identity-border);
25
+ gap: 8px;
26
+ }
27
+ }
28
+
29
+ .identity-account__title {
30
+ font-weight: 600;
31
+ }
32
+
33
+ .identity-account__badge {
34
+ padding: 2px 8px;
35
+ border: var(--identity-border);
36
+ border-radius: 10px;
37
+ font-size: 0.85em;
38
+ }
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+
3
+ import UserAccountPanel from './UserAccountPanel';
4
+ import { USER_ACCOUNT } from '../../stories/fixtures';
5
+
6
+ const meta: Meta<typeof UserAccountPanel> = {
7
+ title: 'Identity/ControlPanel/UserAccountPanel',
8
+ component: UserAccountPanel,
9
+ args: { account: USER_ACCOUNT, loading: false },
10
+ };
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof UserAccountPanel>;
14
+
15
+ /** An account with two providers and a verified address. */
16
+ export const Linked: Story = {};
17
+
18
+ /** A password account: nothing linked, and nothing wrong with that. */
19
+ export const PasswordOnly: Story = {
20
+ args: {
21
+ account: { ...USER_ACCOUNT, identities: [], events: [], events_total: 0 },
22
+ },
23
+ };
24
+
25
+ /**
26
+ * An identity against a provider somebody turned off.
27
+ *
28
+ * This is what a "broken login" report actually looks like from here, and
29
+ * without the badge it reads as nothing at all.
30
+ */
31
+ export const DisabledProvider: Story = {
32
+ args: {
33
+ account: {
34
+ ...USER_ACCOUNT,
35
+ identities: [{ ...USER_ACCOUNT.identities[0], provider_enabled: false }],
36
+ },
37
+ },
38
+ };
39
+
40
+ /** A provider deleted out from under a stored identity. */
41
+ export const RemovedProvider: Story = {
42
+ args: {
43
+ account: {
44
+ ...USER_ACCOUNT,
45
+ identities: [
46
+ {
47
+ ...USER_ACCOUNT.identities[0],
48
+ provider_configured: false,
49
+ provider_enabled: false,
50
+ },
51
+ ],
52
+ },
53
+ },
54
+ };
55
+
56
+ /**
57
+ * Nothing in the retained log.
58
+ *
59
+ * Not the same as never signing in: the log is bounded per user, so a dormant
60
+ * account has had its entries dropped.
61
+ */
62
+ export const Dormant: Story = {
63
+ args: {
64
+ account: {
65
+ ...USER_ACCOUNT,
66
+ last_authenticated: null,
67
+ events: [],
68
+ events_total: 0,
69
+ },
70
+ },
71
+ };
72
+
73
+ export const Loading: Story = { args: { account: null, loading: true } };
74
+
75
+ export const Refused: Story = {
76
+ args: { account: null, loading: false, error: { status: 403 } },
77
+ };