@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,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
|
+
};
|