@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,134 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
3
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
4
|
+
import { Provider } from 'react-redux';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import Identities from './Identities';
|
|
8
|
+
|
|
9
|
+
// Volto's toolbar reads slices of the store this page does not own and does
|
|
10
|
+
// not fill -- content, actions, the breadcrumb trail. It is chrome around the
|
|
11
|
+
// panel rather than anything this test is about, so it is stubbed instead of
|
|
12
|
+
// fed: the alternative is a fixture of Volto's whole app state, which would
|
|
13
|
+
// break on Volto's schedule rather than on this package's. `vi.mock` is
|
|
14
|
+
// hoisted above the imports, so it takes effect despite sitting below them.
|
|
15
|
+
vi.mock('@plone/volto/components/manage/Toolbar/Toolbar', () => ({
|
|
16
|
+
default: () => null,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The container's wiring, which no other test here can see.
|
|
21
|
+
*
|
|
22
|
+
* Both things pinned below are requests and selectors rather than markup:
|
|
23
|
+
* `IdentitiesList` and `ProfileEmails` are rendered with props in their own
|
|
24
|
+
* tests, and a prop handed to them correctly proves nothing about where the
|
|
25
|
+
* container got it. Severing either of these leaves every one of those tests
|
|
26
|
+
* green and the page quietly wrong -- an unexpanded listing makes
|
|
27
|
+
* `canSignInWithLink` false on every site, which reads as "this operator
|
|
28
|
+
* turned the magic link off" rather than as a broken request.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const EMAIL_PROVIDER = {
|
|
32
|
+
'@id': '/@login-providers/email',
|
|
33
|
+
id: 'email',
|
|
34
|
+
title: 'Email',
|
|
35
|
+
driver: 'email',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function storeWith(loginProviders: unknown[]) {
|
|
39
|
+
const state = {
|
|
40
|
+
identities: { loading: false, loaded: true, error: null, data: [] },
|
|
41
|
+
linkableProviders: { loading: false, loaded: true, error: null, data: [] },
|
|
42
|
+
loginProviders: {
|
|
43
|
+
loading: false,
|
|
44
|
+
loaded: true,
|
|
45
|
+
error: null,
|
|
46
|
+
data: loginProviders,
|
|
47
|
+
},
|
|
48
|
+
identityLinking: {},
|
|
49
|
+
identityUnlink: {},
|
|
50
|
+
preferredEmail: {},
|
|
51
|
+
myProfile: {
|
|
52
|
+
loading: false,
|
|
53
|
+
loaded: true,
|
|
54
|
+
error: null,
|
|
55
|
+
data: {
|
|
56
|
+
profile: '/identity-profiles/erico',
|
|
57
|
+
emails: [
|
|
58
|
+
{ address: 'erico@plone.org', verified: false, preferred: true },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
return { state, dispatched: [] as any[] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderIdentities(loginProviders: unknown[] = [EMAIL_PROVIDER]) {
|
|
67
|
+
const { state, dispatched } = storeWith(loginProviders);
|
|
68
|
+
const store = {
|
|
69
|
+
getState: () => state,
|
|
70
|
+
dispatch: (action: any) => {
|
|
71
|
+
dispatched.push(action);
|
|
72
|
+
return action;
|
|
73
|
+
},
|
|
74
|
+
subscribe: () => () => {},
|
|
75
|
+
};
|
|
76
|
+
render(
|
|
77
|
+
<Provider store={store as any}>
|
|
78
|
+
<MemoryRouter initialEntries={['/identities']}>
|
|
79
|
+
<Identities />
|
|
80
|
+
</MemoryRouter>
|
|
81
|
+
</Provider>,
|
|
82
|
+
);
|
|
83
|
+
return { dispatched };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The addresses live behind their own tab, so a test reaches them by name. */
|
|
87
|
+
function openAddresses() {
|
|
88
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Email addresses' }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('Identities', () => {
|
|
92
|
+
// The page portals its toolbar into Volto's `#toolbar`, which only the app
|
|
93
|
+
// shell renders. Without it React throws before anything can be asserted.
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
const toolbar = document.createElement('div');
|
|
96
|
+
toolbar.id = 'toolbar';
|
|
97
|
+
document.body.appendChild(toolbar);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
document.getElementById('toolbar')?.remove();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('asks for the listing with the login providers expanded', () => {
|
|
105
|
+
// Unexpanded, `state.loginProviders` is never filled on this route and
|
|
106
|
+
// the page cannot tell a provider that is off the login page from one
|
|
107
|
+
// that is on it.
|
|
108
|
+
const { dispatched } = renderIdentities();
|
|
109
|
+
|
|
110
|
+
const paths = dispatched
|
|
111
|
+
.filter((action) => action?.request?.path)
|
|
112
|
+
.map((action) => action.request.path);
|
|
113
|
+
expect(paths).toContain('/@identities?expand=login-providers');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('says a verified address signs you in when email is a way in', () => {
|
|
117
|
+
renderIdentities([EMAIL_PROVIDER]);
|
|
118
|
+
openAddresses();
|
|
119
|
+
|
|
120
|
+
expect(document.body.textContent).toContain('sign in with a link');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not, when the operator took email off the login page', () => {
|
|
124
|
+
// `show_in_login` off: the provider is still enabled and still verifies
|
|
125
|
+
// addresses, but nothing on the login page starts that flow.
|
|
126
|
+
renderIdentities([
|
|
127
|
+
{ '@id': '/@login-providers/dex', id: 'dex', driver: 'oidc-generic' },
|
|
128
|
+
]);
|
|
129
|
+
openAddresses();
|
|
130
|
+
|
|
131
|
+
expect(document.body.textContent).not.toContain('sign in with a link');
|
|
132
|
+
expect(document.body.textContent).toContain('recognises you');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identities container: the linking UI in user settings.
|
|
3
|
+
* @module components/Identities/Identities
|
|
4
|
+
*/
|
|
5
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
6
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
7
|
+
import { Link, useHistory, useLocation } from 'react-router-dom';
|
|
8
|
+
import { createPortal } from 'react-dom';
|
|
9
|
+
import { Container, Segment } from 'semantic-ui-react';
|
|
10
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
11
|
+
|
|
12
|
+
import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
|
|
13
|
+
import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
|
|
14
|
+
import { useClient } from '@plone/volto/hooks/client/useClient';
|
|
15
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
16
|
+
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
|
|
17
|
+
import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
|
|
18
|
+
import backSVG from '@plone/volto/icons/back.svg';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
getMyProfile,
|
|
22
|
+
listIdentities,
|
|
23
|
+
setPreferredEmail,
|
|
24
|
+
startLinking,
|
|
25
|
+
unlinkIdentity,
|
|
26
|
+
} from '../../actions';
|
|
27
|
+
import { EMAIL_DRIVER } from '../../helpers/identities';
|
|
28
|
+
import type { Identity, LoginProvider, ProfileEmail } from '../../types';
|
|
29
|
+
import IdentitiesList from './IdentitiesList';
|
|
30
|
+
import { goTo } from '../../helpers/navigate';
|
|
31
|
+
|
|
32
|
+
const messages = defineMessages({
|
|
33
|
+
title: { id: 'Sign-in methods', defaultMessage: 'Sign-in methods' },
|
|
34
|
+
back: { id: 'Back', defaultMessage: 'Back' },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const Identities: React.FC = () => {
|
|
38
|
+
const intl = useIntl();
|
|
39
|
+
const dispatch = useDispatch();
|
|
40
|
+
const isClient = useClient();
|
|
41
|
+
const location = useLocation();
|
|
42
|
+
const { push } = useHistory();
|
|
43
|
+
const { pathname } = location;
|
|
44
|
+
const [redirecting, setRedirecting] = useState(false);
|
|
45
|
+
|
|
46
|
+
const mine = useSelector((state: any) => state.identities);
|
|
47
|
+
const linkableProviders = useSelector(
|
|
48
|
+
(state: any) => state.linkableProviders,
|
|
49
|
+
);
|
|
50
|
+
// The login page's own listing, which arrives expanded on `@identities`.
|
|
51
|
+
// Hidden providers are absent from it -- that is what `show_in_login` means
|
|
52
|
+
// -- so it is the only thing that can say whether a method here is one
|
|
53
|
+
// anybody can actually sign in with.
|
|
54
|
+
const loginProviders = useSelector((state: any) => state.loginProviders);
|
|
55
|
+
const linking = useSelector((state: any) => state.identityLinking);
|
|
56
|
+
const removing = useSelector((state: any) => state.identityUnlink);
|
|
57
|
+
const preferring = useSelector((state: any) => state.preferredEmail);
|
|
58
|
+
// The addresses live on the profile, not on the identity records, so this
|
|
59
|
+
// page needs the one endpoint that reads them off the catalog brain.
|
|
60
|
+
const myProfile = useSelector((state: any) => state.myProfile);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
// Expanded, so the login page's own listing rides along: this page has to
|
|
64
|
+
// know which of these methods actually signs anybody in, and that is the
|
|
65
|
+
// question `@login-providers` answers. One request rather than two, which
|
|
66
|
+
// is what the expandable component exists for.
|
|
67
|
+
dispatch(listIdentities(true));
|
|
68
|
+
dispatch(getMyProfile());
|
|
69
|
+
}, [dispatch]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (redirecting && linking?.loaded && linking?.data?.authorize_url) {
|
|
73
|
+
goTo(linking.data.authorize_url, push, { external: true });
|
|
74
|
+
}
|
|
75
|
+
}, [redirecting, linking, push]);
|
|
76
|
+
|
|
77
|
+
// The email provider answers `sent` instead of an authorize URL, and the
|
|
78
|
+
// page stays where it is to say so.
|
|
79
|
+
const emailSent = Boolean(linking?.loaded && linking?.data?.sent);
|
|
80
|
+
|
|
81
|
+
// An operator who takes the email provider off the login page has turned
|
|
82
|
+
// off signing in with a link. Verifying an address still does its other
|
|
83
|
+
// job -- recognising the same person behind a later provider login -- so
|
|
84
|
+
// what changes here is what this page claims, not what it offers.
|
|
85
|
+
const canSignInWithLink = (loginProviders?.data ?? []).some(
|
|
86
|
+
(provider: LoginProvider) => provider.driver === EMAIL_DRIVER,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (removing?.loaded) {
|
|
91
|
+
// The list is stale the moment an unlink succeeds, and can_unlink may
|
|
92
|
+
// have changed for everything left. So are the addresses: removing an
|
|
93
|
+
// email identity is exactly what un-verifies one.
|
|
94
|
+
dispatch(listIdentities(true));
|
|
95
|
+
dispatch(getMyProfile());
|
|
96
|
+
}
|
|
97
|
+
}, [dispatch, removing?.loaded]);
|
|
98
|
+
|
|
99
|
+
const onLink = useCallback(
|
|
100
|
+
(provider: LoginProvider) => {
|
|
101
|
+
setRedirecting(true);
|
|
102
|
+
dispatch(startLinking(provider.id, location.pathname));
|
|
103
|
+
},
|
|
104
|
+
[dispatch, location.pathname],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const onVerifyEmail = useCallback(
|
|
108
|
+
(address: string) => {
|
|
109
|
+
// No `setRedirecting` here: nothing is going to navigate. The flow
|
|
110
|
+
// continues when the link in the message is clicked, which may well be
|
|
111
|
+
// in another browser entirely.
|
|
112
|
+
//
|
|
113
|
+
// The provider id is the driver id, which is what this site's email
|
|
114
|
+
// provider is called by convention and what the backend's own
|
|
115
|
+
// `EMAIL_PROVIDER` is. A site that renamed it would need the id off the
|
|
116
|
+
// listing instead -- but the listing deliberately does not carry the
|
|
117
|
+
// email provider any more, which is the whole point of this panel.
|
|
118
|
+
dispatch(startLinking(EMAIL_DRIVER, location.pathname, address));
|
|
119
|
+
},
|
|
120
|
+
[dispatch, location.pathname],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const onPreferEmail = useCallback(
|
|
124
|
+
(address: string) => {
|
|
125
|
+
// The whole list, reordered here rather than on the backend: `emails`
|
|
126
|
+
// is a field, and a PATCH carrying one entry would replace the list
|
|
127
|
+
// with it. The rest keep their order, so this moves one address and
|
|
128
|
+
// rearranges nothing else.
|
|
129
|
+
const profileUrl = myProfile?.data?.profile;
|
|
130
|
+
if (!profileUrl) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const addresses: string[] = (myProfile?.data?.emails ?? []).map(
|
|
134
|
+
(entry: ProfileEmail) => entry.address,
|
|
135
|
+
);
|
|
136
|
+
dispatch(
|
|
137
|
+
setPreferredEmail(flattenToAppURL(profileUrl), [
|
|
138
|
+
address,
|
|
139
|
+
...addresses.filter((each) => each !== address),
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
[dispatch, myProfile],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (preferring?.loaded) {
|
|
148
|
+
// A PATCH answers with no body, so the new order comes from asking
|
|
149
|
+
// again -- and `preferred` is derived, so the answer is the only place
|
|
150
|
+
// that knows which address actually won.
|
|
151
|
+
dispatch(getMyProfile());
|
|
152
|
+
}
|
|
153
|
+
}, [dispatch, preferring?.loaded]);
|
|
154
|
+
|
|
155
|
+
const onUnlink = useCallback(
|
|
156
|
+
(identity: Identity) => {
|
|
157
|
+
dispatch(unlinkIdentity(identity.provider, identity.subject));
|
|
158
|
+
},
|
|
159
|
+
[dispatch],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
// The page chrome Volto's own user-settings pages have: a container that
|
|
164
|
+
// centres the content, a titled panel, and the toolbar. Without it this
|
|
165
|
+
// route rendered as a bare list against the left edge of the window.
|
|
166
|
+
<Container id="page-identities">
|
|
167
|
+
<Helmet title={intl.formatMessage(messages.title)} />
|
|
168
|
+
<Segment.Group raised>
|
|
169
|
+
<Segment className="primary">
|
|
170
|
+
{intl.formatMessage(messages.title)}
|
|
171
|
+
</Segment>
|
|
172
|
+
<Segment>
|
|
173
|
+
<IdentitiesList
|
|
174
|
+
identities={mine?.data ?? []}
|
|
175
|
+
available={linkableProviders?.data ?? []}
|
|
176
|
+
emails={myProfile?.data?.emails ?? []}
|
|
177
|
+
canSignInWithLink={canSignInWithLink}
|
|
178
|
+
profileUrl={myProfile?.data?.profile ?? null}
|
|
179
|
+
loading={Boolean(mine?.loading)}
|
|
180
|
+
busy={
|
|
181
|
+
redirecting ||
|
|
182
|
+
Boolean(removing?.loading) ||
|
|
183
|
+
Boolean(preferring?.loading) ||
|
|
184
|
+
Boolean(linking?.loading)
|
|
185
|
+
}
|
|
186
|
+
error={linking?.error ?? removing?.error}
|
|
187
|
+
emailSent={emailSent}
|
|
188
|
+
onLink={onLink}
|
|
189
|
+
onVerifyEmail={onVerifyEmail}
|
|
190
|
+
onPreferEmail={onPreferEmail}
|
|
191
|
+
onUnlink={onUnlink}
|
|
192
|
+
/>
|
|
193
|
+
</Segment>
|
|
194
|
+
</Segment.Group>
|
|
195
|
+
{isClient &&
|
|
196
|
+
createPortal(
|
|
197
|
+
<Toolbar
|
|
198
|
+
pathname={pathname}
|
|
199
|
+
hideDefaultViewButtons
|
|
200
|
+
inner={
|
|
201
|
+
<Link to={`${getBaseUrl(pathname)}`} className="item">
|
|
202
|
+
<Icon
|
|
203
|
+
name={backSVG}
|
|
204
|
+
className="contents circled"
|
|
205
|
+
size="30px"
|
|
206
|
+
title={intl.formatMessage(messages.back)}
|
|
207
|
+
/>
|
|
208
|
+
</Link>
|
|
209
|
+
}
|
|
210
|
+
/>,
|
|
211
|
+
document.getElementById('toolbar') as HTMLElement,
|
|
212
|
+
)}
|
|
213
|
+
</Container>
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export default Identities;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "your sign-in methods" panel.
|
|
3
|
+
*
|
|
4
|
+
* No border or shadow of its own: the container that renders it already puts
|
|
5
|
+
* it inside a raised Segment, and a card inside a card reads as a mistake.
|
|
6
|
+
*/
|
|
7
|
+
.identity-identities__list {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
padding: 0;
|
|
11
|
+
margin: 0 0 var(--identity-gap);
|
|
12
|
+
gap: 8px;
|
|
13
|
+
list-style: none;
|
|
14
|
+
|
|
15
|
+
li {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: baseline;
|
|
18
|
+
padding: 10px 0;
|
|
19
|
+
border-bottom: var(--identity-border);
|
|
20
|
+
gap: 8px;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.identity-identities__title {
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.identity-identities__add {
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-wrap: wrap;
|
|
31
|
+
align-items: baseline;
|
|
32
|
+
margin-top: calc(var(--identity-gap) * 2);
|
|
33
|
+
gap: 8px;
|
|
34
|
+
|
|
35
|
+
h3 {
|
|
36
|
+
margin: 0;
|
|
37
|
+
font-size: 16px;
|
|
38
|
+
font-weight: 500;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
ul {
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-wrap: wrap;
|
|
44
|
+
padding: 0;
|
|
45
|
+
margin: 0;
|
|
46
|
+
gap: 8px;
|
|
47
|
+
list-style: none;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import IdentitiesList from './IdentitiesList';
|
|
4
|
+
import {
|
|
5
|
+
FAILED,
|
|
6
|
+
IDENTITIES,
|
|
7
|
+
ONLY_IDENTITY,
|
|
8
|
+
PROFILE_EMAILS,
|
|
9
|
+
PROVIDERS,
|
|
10
|
+
STYLED,
|
|
11
|
+
} from '../../stories/fixtures';
|
|
12
|
+
|
|
13
|
+
const meta: Meta<typeof IdentitiesList> = {
|
|
14
|
+
title: 'Identity/Identities/IdentitiesList',
|
|
15
|
+
component: IdentitiesList,
|
|
16
|
+
args: {
|
|
17
|
+
identities: IDENTITIES,
|
|
18
|
+
available: PROVIDERS,
|
|
19
|
+
emails: PROFILE_EMAILS,
|
|
20
|
+
profileUrl: '/identity-profiles/erico',
|
|
21
|
+
loading: false,
|
|
22
|
+
busy: false,
|
|
23
|
+
emailSent: false,
|
|
24
|
+
canSignInWithLink: true,
|
|
25
|
+
onLink: () => {},
|
|
26
|
+
onVerifyEmail: () => {},
|
|
27
|
+
onUnlink: () => {},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
export default meta;
|
|
31
|
+
|
|
32
|
+
type Story = StoryObj<typeof IdentitiesList>;
|
|
33
|
+
|
|
34
|
+
export const Several: Story = {};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The last way in cannot be unlinked: doing so would lock the user out of an
|
|
38
|
+
* account they have no password for.
|
|
39
|
+
*/
|
|
40
|
+
export const LastRemaining: Story = { args: { identities: ONLY_IDENTITY } };
|
|
41
|
+
|
|
42
|
+
/** One that has never been used since it was linked. */
|
|
43
|
+
export const NeverUsed: Story = {
|
|
44
|
+
args: { identities: [{ ...IDENTITIES[0], last_login: null }] },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const Empty: Story = { args: { identities: [] } };
|
|
48
|
+
|
|
49
|
+
export const Loading: Story = { args: { loading: true, identities: [] } };
|
|
50
|
+
|
|
51
|
+
/** While a link or unlink is in flight, nothing else may be started. */
|
|
52
|
+
export const Busy: Story = { args: { busy: true } };
|
|
53
|
+
|
|
54
|
+
export const Failed: Story = { args: { error: FAILED.error } };
|
|
55
|
+
|
|
56
|
+
/** Every provider already linked, so there is nothing left to add. */
|
|
57
|
+
export const NothingLeftToLink: Story = { args: { available: [] } };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A provider wearing the icon and colours an operator gave it.
|
|
61
|
+
*
|
|
62
|
+
* The same button the login screen draws: the identities page reads the
|
|
63
|
+
* backend's `available` listing, which carries the style for exactly this.
|
|
64
|
+
*/
|
|
65
|
+
export const StyledProvider: Story = { args: { available: [STYLED] } };
|
|
66
|
+
|
|
67
|
+
/** Nothing on the profile to verify, so it points at the profile instead. */
|
|
68
|
+
export const NoAddresses: Story = { args: { emails: [] } };
|
|
69
|
+
|
|
70
|
+
/** A confirmation link has gone out and the page stays where it is. */
|
|
71
|
+
export const AddressSent: Story = { args: { emailSent: true } };
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import IdentitiesList from './IdentitiesList';
|
|
6
|
+
import type { Identity, LoginProvider, ProfileEmail } from '../../types';
|
|
7
|
+
|
|
8
|
+
const DEX: Identity = {
|
|
9
|
+
'@id': '/@identities/dex/subject-1',
|
|
10
|
+
provider: 'dex',
|
|
11
|
+
subject: 'subject-1',
|
|
12
|
+
title: 'Dex',
|
|
13
|
+
created: '2026-08-21T10:00:00+00:00',
|
|
14
|
+
last_login: null,
|
|
15
|
+
can_unlink: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const GITHUB: LoginProvider = {
|
|
19
|
+
'@id': '/@login-providers/github',
|
|
20
|
+
id: 'github',
|
|
21
|
+
title: 'GitHub',
|
|
22
|
+
driver: 'github',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ADDRESS: ProfileEmail = {
|
|
26
|
+
address: 'erico@plone.org',
|
|
27
|
+
verified: false,
|
|
28
|
+
preferred: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function renderList(
|
|
32
|
+
props: Partial<React.ComponentProps<typeof IdentitiesList>> = {},
|
|
33
|
+
) {
|
|
34
|
+
const onLink = vi.fn();
|
|
35
|
+
const onVerifyEmail = vi.fn();
|
|
36
|
+
const onUnlink = vi.fn();
|
|
37
|
+
render(
|
|
38
|
+
<IdentitiesList
|
|
39
|
+
identities={[DEX]}
|
|
40
|
+
available={[GITHUB]}
|
|
41
|
+
emails={[ADDRESS]}
|
|
42
|
+
canSignInWithLink
|
|
43
|
+
loading={false}
|
|
44
|
+
busy={false}
|
|
45
|
+
emailSent={false}
|
|
46
|
+
onLink={onLink}
|
|
47
|
+
onVerifyEmail={onVerifyEmail}
|
|
48
|
+
onUnlink={onUnlink}
|
|
49
|
+
{...props}
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
return { onLink, onVerifyEmail, onUnlink };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('IdentitiesList', () => {
|
|
56
|
+
it('lists what the user owns', () => {
|
|
57
|
+
renderList();
|
|
58
|
+
|
|
59
|
+
expect(screen.getByText('Dex')).toBeTruthy();
|
|
60
|
+
expect(screen.getByText('subject-1')).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('offers what they can still add', () => {
|
|
64
|
+
renderList();
|
|
65
|
+
|
|
66
|
+
expect(screen.getByText('GitHub')).toBeTruthy();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reports the provider to link', () => {
|
|
70
|
+
const { onLink } = renderList();
|
|
71
|
+
|
|
72
|
+
fireEvent.click(screen.getByText('GitHub'));
|
|
73
|
+
|
|
74
|
+
expect(onLink).toHaveBeenCalledWith(GITHUB);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reports the identity to remove', () => {
|
|
78
|
+
const { onUnlink } = renderList();
|
|
79
|
+
|
|
80
|
+
fireEvent.click(screen.getByText('Remove'));
|
|
81
|
+
|
|
82
|
+
expect(onUnlink).toHaveBeenCalledWith(DEX);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('refuses to offer removing the last way in', () => {
|
|
86
|
+
// Surfaced. A button that only fails when pressed is worse than one
|
|
87
|
+
// that explains itself.
|
|
88
|
+
renderList({ identities: [{ ...DEX, can_unlink: false }] });
|
|
89
|
+
|
|
90
|
+
const button = screen.getByText('Remove') as HTMLButtonElement;
|
|
91
|
+
expect(button.disabled).toBe(true);
|
|
92
|
+
expect(button.title).toContain('only way to sign in');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('disables everything while something is in flight', () => {
|
|
96
|
+
renderList({ busy: true });
|
|
97
|
+
|
|
98
|
+
for (const button of screen.getAllByRole('button')) {
|
|
99
|
+
expect((button as HTMLButtonElement).disabled).toBe(true);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('says so while loading', () => {
|
|
104
|
+
renderList({ loading: true });
|
|
105
|
+
|
|
106
|
+
expect(screen.getByRole('status').textContent).toContain('Loading');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('says so when nothing is linked', () => {
|
|
110
|
+
renderList({ identities: [] });
|
|
111
|
+
|
|
112
|
+
expect(screen.getByText(/no external sign-in methods/i)).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('offers no add section when there is nothing to add', () => {
|
|
116
|
+
renderList({ available: [] });
|
|
117
|
+
|
|
118
|
+
expect(screen.queryByText('Add another')).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('reports a failure', () => {
|
|
122
|
+
renderList({ error: { status: 409 } });
|
|
123
|
+
|
|
124
|
+
expect(screen.getByRole('alert')).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Select one of the panel's tabs by its label.
|
|
130
|
+
*
|
|
131
|
+
* Only the selected panel is in the document, so the addresses are reached by
|
|
132
|
+
* pressing their tab rather than by querying for them.
|
|
133
|
+
*/
|
|
134
|
+
function openTab(label: string) {
|
|
135
|
+
fireEvent.click(screen.getByRole('tab', { name: label }));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe('IdentitiesList sections', () => {
|
|
139
|
+
it('splits the page into the two questions it answers', () => {
|
|
140
|
+
// Stacked, this read as a pile of everything that could be said about how
|
|
141
|
+
// you get in, with the addresses below whatever length the provider list
|
|
142
|
+
// happened to be.
|
|
143
|
+
renderList();
|
|
144
|
+
|
|
145
|
+
const tabs = screen.getAllByRole('tab').map((tab) => tab.textContent);
|
|
146
|
+
expect(tabs).toEqual(['Sign-in methods', 'Email addresses']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('opens on the methods rather than the addresses', () => {
|
|
150
|
+
renderList();
|
|
151
|
+
|
|
152
|
+
expect(screen.getByRole('tab', { selected: true }).textContent).toBe(
|
|
153
|
+
'Sign-in methods',
|
|
154
|
+
);
|
|
155
|
+
expect(screen.queryByText('Your email addresses')).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('wears the same tabs the account page does', () => {
|
|
159
|
+
// The control panel's account page is this question asked by an
|
|
160
|
+
// administrator; the two being recognisably one design is the point.
|
|
161
|
+
renderList();
|
|
162
|
+
|
|
163
|
+
expect(document.querySelector('.identity-tabs')).toBeTruthy();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('IdentitiesList and your own addresses', () => {
|
|
168
|
+
it('lists the addresses on the profile', () => {
|
|
169
|
+
// Not a box to type one into. The address a magic link proves is
|
|
170
|
+
// whatever was typed, so a free-text field here verified any mailbox at
|
|
171
|
+
// all -- and a verified address is what a new provider account can be
|
|
172
|
+
// auto-attached to.
|
|
173
|
+
renderList();
|
|
174
|
+
openTab('Email addresses');
|
|
175
|
+
|
|
176
|
+
expect(screen.queryByLabelText('Email address')).toBeNull();
|
|
177
|
+
expect(screen.getByText('erico@plone.org')).toBeTruthy();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('offers to verify one that is not verified yet', () => {
|
|
181
|
+
const { onVerifyEmail } = renderList();
|
|
182
|
+
openTab('Email addresses');
|
|
183
|
+
|
|
184
|
+
fireEvent.click(screen.getByRole('button', { name: 'Verify' }));
|
|
185
|
+
|
|
186
|
+
expect(onVerifyEmail).toHaveBeenCalledWith('erico@plone.org');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('says so rather than offering to verify a verified one', () => {
|
|
190
|
+
renderList({ emails: [{ ...ADDRESS, verified: true }] });
|
|
191
|
+
openTab('Email addresses');
|
|
192
|
+
|
|
193
|
+
expect(screen.queryByRole('button', { name: 'Verify' })).toBeNull();
|
|
194
|
+
expect(screen.getByText('Verified')).toBeTruthy();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('marks the address the site uses', () => {
|
|
198
|
+
renderList();
|
|
199
|
+
openTab('Email addresses');
|
|
200
|
+
|
|
201
|
+
expect(screen.getByText('Preferred')).toBeTruthy();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('says the mail is out once it is', () => {
|
|
205
|
+
renderList({ emailSent: true });
|
|
206
|
+
openTab('Email addresses');
|
|
207
|
+
|
|
208
|
+
expect(screen.getByRole('status')).toBeTruthy();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('points at the profile when there is nothing to verify', () => {
|
|
212
|
+
renderList({ emails: [], profileUrl: '/identity-profiles/erico' });
|
|
213
|
+
openTab('Email addresses');
|
|
214
|
+
|
|
215
|
+
expect(screen.getByText(/carries no email address/i)).toBeTruthy();
|
|
216
|
+
expect(screen.getByText('Edit your profile')).toBeTruthy();
|
|
217
|
+
});
|
|
218
|
+
});
|