@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,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "your sign-in methods" panel, without store or routing.
|
|
3
|
+
*
|
|
4
|
+
* Two sections, and they are tabs rather than a column: the providers you
|
|
5
|
+
* sign in through, and the addresses that are yours. They were stacked, so
|
|
6
|
+
* the page read as a pile of everything that could be said about how you get
|
|
7
|
+
* in -- and the addresses, which is where the work usually is, were below
|
|
8
|
+
* whatever length the provider list happened to be.
|
|
9
|
+
*
|
|
10
|
+
* The same tabs the control panel's account page wears, because this is that
|
|
11
|
+
* page's question asked by the account's owner rather than by an
|
|
12
|
+
* administrator.
|
|
13
|
+
* @module components/Identities/IdentitiesList
|
|
14
|
+
*/
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
17
|
+
import { Tabs } from '@plone/components';
|
|
18
|
+
import { Tab, TabList, TabPanel } from 'react-aria-components';
|
|
19
|
+
|
|
20
|
+
// `@plone/components` ships its CSS separately from its components; see the
|
|
21
|
+
// note on the same import in `UserAccountPanel`.
|
|
22
|
+
import '@plone/components/src/styles/basic/Tabs.css';
|
|
23
|
+
|
|
24
|
+
import type { Identity, LoginProvider, ProfileEmail } from '../../types';
|
|
25
|
+
import ProviderButton from '../Login/ProviderButton';
|
|
26
|
+
import ProfileEmails from './ProfileEmails';
|
|
27
|
+
|
|
28
|
+
import './IdentitiesList.scss';
|
|
29
|
+
|
|
30
|
+
const messages = defineMessages({
|
|
31
|
+
loading: {
|
|
32
|
+
id: 'Loading your sign-in methods',
|
|
33
|
+
defaultMessage: 'Loading your sign-in methods…',
|
|
34
|
+
},
|
|
35
|
+
remove: { id: 'Remove', defaultMessage: 'Remove' },
|
|
36
|
+
lastWayIn: {
|
|
37
|
+
id: 'This is your only way to sign in. Add another first.',
|
|
38
|
+
defaultMessage: 'This is your only way to sign in. Add another first.',
|
|
39
|
+
},
|
|
40
|
+
empty: {
|
|
41
|
+
id: 'You have no external sign-in methods linked yet.',
|
|
42
|
+
defaultMessage: 'You have no external sign-in methods linked yet.',
|
|
43
|
+
},
|
|
44
|
+
addAnother: { id: 'Add another', defaultMessage: 'Add another' },
|
|
45
|
+
providers: { id: 'Sign-in methods', defaultMessage: 'Sign-in methods' },
|
|
46
|
+
addresses: { id: 'Email addresses', defaultMessage: 'Email addresses' },
|
|
47
|
+
sections: {
|
|
48
|
+
id: 'identities-sections',
|
|
49
|
+
defaultMessage: 'How you sign in',
|
|
50
|
+
},
|
|
51
|
+
failed: {
|
|
52
|
+
id: 'That did not work. Please try again.',
|
|
53
|
+
defaultMessage: 'That did not work. Please try again.',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
interface IdentitiesListProps {
|
|
58
|
+
identities: Identity[];
|
|
59
|
+
/**
|
|
60
|
+
* Providers that could still be added.
|
|
61
|
+
*
|
|
62
|
+
* The backend's own `available`, not a filter applied here. It is the
|
|
63
|
+
* *enabled* providers minus the ones already linked, which is a different
|
|
64
|
+
* question from the login screen's listing -- a provider an operator has
|
|
65
|
+
* taken off the login page is still one an existing user may attach.
|
|
66
|
+
*/
|
|
67
|
+
available: LoginProvider[];
|
|
68
|
+
/** The caller's own addresses, and which of them are verified. */
|
|
69
|
+
emails: ProfileEmail[];
|
|
70
|
+
/** Where the caller's profile is, when they have one. */
|
|
71
|
+
profileUrl?: string | null;
|
|
72
|
+
loading: boolean;
|
|
73
|
+
busy: boolean;
|
|
74
|
+
error?: unknown;
|
|
75
|
+
/** Whether a confirmation mail has gone out for an address. */
|
|
76
|
+
emailSent: boolean;
|
|
77
|
+
onLink: (provider: LoginProvider) => void;
|
|
78
|
+
/**
|
|
79
|
+
* Whether the email provider is one of the ways in on the login page.
|
|
80
|
+
*
|
|
81
|
+
* Passed straight to `ProfileEmails`, where the reason it matters is
|
|
82
|
+
* written down.
|
|
83
|
+
*/
|
|
84
|
+
canSignInWithLink: boolean;
|
|
85
|
+
onVerifyEmail: (address: string) => void;
|
|
86
|
+
/** Move an address to the front of the caller's list, when they can. */
|
|
87
|
+
onPreferEmail?: (address: string) => void;
|
|
88
|
+
onUnlink: (identity: Identity) => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const IdentitiesList: React.FC<IdentitiesListProps> = ({
|
|
92
|
+
identities,
|
|
93
|
+
available,
|
|
94
|
+
canSignInWithLink,
|
|
95
|
+
emails,
|
|
96
|
+
profileUrl,
|
|
97
|
+
loading,
|
|
98
|
+
busy,
|
|
99
|
+
error,
|
|
100
|
+
emailSent,
|
|
101
|
+
onLink,
|
|
102
|
+
onVerifyEmail,
|
|
103
|
+
onPreferEmail,
|
|
104
|
+
onUnlink,
|
|
105
|
+
}) => {
|
|
106
|
+
const intl = useIntl();
|
|
107
|
+
|
|
108
|
+
if (loading) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="identity-identities" role="status">
|
|
111
|
+
{intl.formatMessage(messages.loading)}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="identity-identities">
|
|
118
|
+
<Tabs className="identity-tabs">
|
|
119
|
+
<TabList aria-label={intl.formatMessage(messages.sections)}>
|
|
120
|
+
<Tab id="providers">{intl.formatMessage(messages.providers)}</Tab>
|
|
121
|
+
<Tab id="addresses">{intl.formatMessage(messages.addresses)}</Tab>
|
|
122
|
+
</TabList>
|
|
123
|
+
|
|
124
|
+
<TabPanel id="providers">
|
|
125
|
+
<ul className="identity-identities__list">
|
|
126
|
+
{identities.map((identity) => (
|
|
127
|
+
<li key={identity['@id']} data-provider={identity.provider}>
|
|
128
|
+
<span className="identity-identities__title">
|
|
129
|
+
{identity.title}
|
|
130
|
+
</span>
|
|
131
|
+
<span className="identity-identities__subject identity-note">
|
|
132
|
+
{identity.subject}
|
|
133
|
+
</span>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
className="identity-button identity-button--danger"
|
|
137
|
+
disabled={busy || !identity.can_unlink}
|
|
138
|
+
// Surfaced: the backend refuses to remove the last way in,
|
|
139
|
+
// and a button that only fails when pressed is worse than one
|
|
140
|
+
// that explains itself.
|
|
141
|
+
title={
|
|
142
|
+
identity.can_unlink
|
|
143
|
+
? undefined
|
|
144
|
+
: intl.formatMessage(messages.lastWayIn)
|
|
145
|
+
}
|
|
146
|
+
data-action="unlink"
|
|
147
|
+
onClick={() => onUnlink(identity)}
|
|
148
|
+
>
|
|
149
|
+
{intl.formatMessage(messages.remove)}
|
|
150
|
+
</button>
|
|
151
|
+
</li>
|
|
152
|
+
))}
|
|
153
|
+
</ul>
|
|
154
|
+
|
|
155
|
+
{identities.length === 0 ? (
|
|
156
|
+
<p className="identity-identities__empty identity-note">
|
|
157
|
+
{intl.formatMessage(messages.empty)}
|
|
158
|
+
</p>
|
|
159
|
+
) : null}
|
|
160
|
+
|
|
161
|
+
{available.length ? (
|
|
162
|
+
<div className="identity-identities__add">
|
|
163
|
+
<h3>{intl.formatMessage(messages.addAnother)}</h3>
|
|
164
|
+
<ul>
|
|
165
|
+
{available.map((provider) => (
|
|
166
|
+
<li key={provider.id}>
|
|
167
|
+
<ProviderButton
|
|
168
|
+
id={provider.id}
|
|
169
|
+
driver={provider.driver}
|
|
170
|
+
label={provider.title || provider.id}
|
|
171
|
+
icon={provider.icon}
|
|
172
|
+
background_color={provider.background_color}
|
|
173
|
+
foreground_color={provider.foreground_color}
|
|
174
|
+
disabled={busy}
|
|
175
|
+
onSelect={() => onLink(provider)}
|
|
176
|
+
/>
|
|
177
|
+
</li>
|
|
178
|
+
))}
|
|
179
|
+
</ul>
|
|
180
|
+
</div>
|
|
181
|
+
) : null}
|
|
182
|
+
</TabPanel>
|
|
183
|
+
|
|
184
|
+
<TabPanel id="addresses">
|
|
185
|
+
<ProfileEmails
|
|
186
|
+
emails={emails}
|
|
187
|
+
profileUrl={profileUrl}
|
|
188
|
+
loading={loading}
|
|
189
|
+
busy={busy}
|
|
190
|
+
sent={emailSent}
|
|
191
|
+
canSignInWithLink={canSignInWithLink}
|
|
192
|
+
onVerify={onVerifyEmail}
|
|
193
|
+
onPrefer={onPreferEmail}
|
|
194
|
+
/>
|
|
195
|
+
</TabPanel>
|
|
196
|
+
</Tabs>
|
|
197
|
+
|
|
198
|
+
{error ? (
|
|
199
|
+
<p className="identity-error" role="alert">
|
|
200
|
+
{intl.formatMessage(messages.failed)}
|
|
201
|
+
</p>
|
|
202
|
+
) : null}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export default IdentitiesList;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Your addresses, and the button that proves one.
|
|
3
|
+
*
|
|
4
|
+
* Shares the geometry of the sign-in-methods list above it: the two panels
|
|
5
|
+
* sit in the same Segment and a second set of measurements would read as two
|
|
6
|
+
* pages stapled together.
|
|
7
|
+
*/
|
|
8
|
+
.identity-emails {
|
|
9
|
+
margin-top: var(--identity-gap);
|
|
10
|
+
|
|
11
|
+
h3 {
|
|
12
|
+
margin-bottom: 4px;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.identity-emails__list {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
padding: 0;
|
|
20
|
+
margin: var(--identity-gap) 0;
|
|
21
|
+
gap: 8px;
|
|
22
|
+
list-style: none;
|
|
23
|
+
|
|
24
|
+
li {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: baseline;
|
|
27
|
+
padding: 10px 0;
|
|
28
|
+
border-bottom: var(--identity-border);
|
|
29
|
+
gap: 8px;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.identity-emails__address {
|
|
34
|
+
flex: 1;
|
|
35
|
+
font-weight: 600;
|
|
36
|
+
/* An address is long and has no spaces to wrap at, so it is the one thing
|
|
37
|
+
on the row that must be allowed to break rather than push the buttons
|
|
38
|
+
off the end. */
|
|
39
|
+
overflow-wrap: anywhere;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.identity-emails__badge {
|
|
43
|
+
padding: 2px 8px;
|
|
44
|
+
border: var(--identity-border);
|
|
45
|
+
border-radius: 10px;
|
|
46
|
+
font-size: 0.85em;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.identity-emails__badge--verified {
|
|
50
|
+
border-color: var(--identity-verified-border, #1a7f37);
|
|
51
|
+
color: var(--identity-verified-color, #1a7f37);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* A second action on the same row as `Verify`, and quieter than it: choosing
|
|
55
|
+
a preferred address is housekeeping, while proving one is the thing this
|
|
56
|
+
panel is for. */
|
|
57
|
+
.identity-button--quiet {
|
|
58
|
+
border-color: transparent;
|
|
59
|
+
background: none;
|
|
60
|
+
font-weight: 400;
|
|
61
|
+
text-decoration: underline;
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import ProfileEmails from './ProfileEmails';
|
|
4
|
+
import { PROFILE_EMAILS } from '../../stories/fixtures';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof ProfileEmails> = {
|
|
7
|
+
title: 'Identity/Identities/ProfileEmails',
|
|
8
|
+
component: ProfileEmails,
|
|
9
|
+
args: {
|
|
10
|
+
emails: PROFILE_EMAILS,
|
|
11
|
+
profileUrl: '/identity-profiles/erico',
|
|
12
|
+
loading: false,
|
|
13
|
+
busy: false,
|
|
14
|
+
sent: false,
|
|
15
|
+
canSignInWithLink: true,
|
|
16
|
+
onVerify: () => {},
|
|
17
|
+
onPrefer: () => {},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof ProfileEmails>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The same panel on a site that took the email provider off its login page.
|
|
26
|
+
*
|
|
27
|
+
* Verifying still recognises the person behind a later provider login, so the
|
|
28
|
+
* button stays -- but signing in with a link is not one of the ways in here,
|
|
29
|
+
* and the panel stops saying it is.
|
|
30
|
+
*/
|
|
31
|
+
export const WithoutLinkSignIn: Story = {
|
|
32
|
+
args: { canSignInWithLink: false },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** One address proved, one still to prove. */
|
|
36
|
+
export const Several: Story = {};
|
|
37
|
+
|
|
38
|
+
/** Nothing verified yet, which is how a profile arrives from a provider. */
|
|
39
|
+
export const NoneVerified: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
emails: PROFILE_EMAILS.map((entry) => ({ ...entry, verified: false })),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* No addresses at all.
|
|
47
|
+
*
|
|
48
|
+
* There is nothing to verify and no box to type one into: an address is added
|
|
49
|
+
* on the profile, and this points at it.
|
|
50
|
+
*/
|
|
51
|
+
export const Empty: Story = { args: { emails: [] } };
|
|
52
|
+
|
|
53
|
+
/** A confirmation link has gone out. Nothing is verified until it is clicked,
|
|
54
|
+
* which may well happen in another browser entirely. */
|
|
55
|
+
export const Sent: Story = { args: { sent: true } };
|
|
56
|
+
|
|
57
|
+
/** While a send is in flight, nothing else may be started. */
|
|
58
|
+
export const Busy: Story = { args: { busy: true } };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A page that only shows the addresses.
|
|
62
|
+
*
|
|
63
|
+
* Without a handler the buttons are not rendered at all, rather than rendered
|
|
64
|
+
* inert: an action that does nothing when clicked is worse than one that was
|
|
65
|
+
* never offered.
|
|
66
|
+
*/
|
|
67
|
+
export const NoReordering: Story = { args: { onPrefer: undefined } };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One address, so there is nothing to choose between.
|
|
71
|
+
*
|
|
72
|
+
* The hint above the list is what disappears; the address is preferred by
|
|
73
|
+
* being the only one.
|
|
74
|
+
*/
|
|
75
|
+
export const OnlyOne: Story = { args: { emails: PROFILE_EMAILS.slice(0, 1) } };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import ProfileEmails from './ProfileEmails';
|
|
6
|
+
import type { ProfileEmail } from '../../types';
|
|
7
|
+
|
|
8
|
+
const PREFERRED: ProfileEmail = {
|
|
9
|
+
address: 'erico@plone.org',
|
|
10
|
+
verified: true,
|
|
11
|
+
preferred: true,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const OTHER: ProfileEmail = {
|
|
15
|
+
address: 'erico@example.com',
|
|
16
|
+
verified: false,
|
|
17
|
+
preferred: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function renderPanel(
|
|
21
|
+
props: Partial<React.ComponentProps<typeof ProfileEmails>> = {},
|
|
22
|
+
) {
|
|
23
|
+
const onVerify = vi.fn();
|
|
24
|
+
const onPrefer = vi.fn();
|
|
25
|
+
render(
|
|
26
|
+
<ProfileEmails
|
|
27
|
+
emails={[PREFERRED, OTHER]}
|
|
28
|
+
canSignInWithLink
|
|
29
|
+
loading={false}
|
|
30
|
+
busy={false}
|
|
31
|
+
sent={false}
|
|
32
|
+
onVerify={onVerify}
|
|
33
|
+
onPrefer={onPrefer}
|
|
34
|
+
{...props}
|
|
35
|
+
/>,
|
|
36
|
+
);
|
|
37
|
+
return { onVerify, onPrefer };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('ProfileEmails', () => {
|
|
41
|
+
it('lists every address on the profile', () => {
|
|
42
|
+
renderPanel();
|
|
43
|
+
|
|
44
|
+
expect(screen.getByText('erico@plone.org')).toBeTruthy();
|
|
45
|
+
expect(screen.getByText('erico@example.com')).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('marks the one this site uses', () => {
|
|
49
|
+
renderPanel();
|
|
50
|
+
|
|
51
|
+
expect(screen.getByText('Preferred')).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('choosing which address stands for you', () => {
|
|
55
|
+
it('offers it on every address but the one already chosen', () => {
|
|
56
|
+
renderPanel();
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
screen.getAllByRole('button', { name: 'Make preferred' }),
|
|
60
|
+
).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('hands back the address that was picked', () => {
|
|
64
|
+
const { onPrefer } = renderPanel();
|
|
65
|
+
|
|
66
|
+
fireEvent.click(screen.getByRole('button', { name: 'Make preferred' }));
|
|
67
|
+
|
|
68
|
+
expect(onPrefer).toHaveBeenCalledWith('erico@example.com');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('is not offered at all without a handler', () => {
|
|
72
|
+
// Rendered inert would be worse than never offered: a button that does
|
|
73
|
+
// nothing when clicked reads as a broken page.
|
|
74
|
+
renderPanel({ onPrefer: undefined });
|
|
75
|
+
|
|
76
|
+
expect(
|
|
77
|
+
screen.queryByRole('button', { name: 'Make preferred' }),
|
|
78
|
+
).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('says nothing about ordering when there is one address', () => {
|
|
82
|
+
renderPanel({ emails: [PREFERRED] });
|
|
83
|
+
|
|
84
|
+
expect(
|
|
85
|
+
screen.queryByRole('button', { name: 'Make preferred' }),
|
|
86
|
+
).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('is held while something else is in flight', () => {
|
|
90
|
+
renderPanel({ busy: true });
|
|
91
|
+
|
|
92
|
+
expect(
|
|
93
|
+
screen
|
|
94
|
+
.getByRole('button', { name: 'Make preferred' })
|
|
95
|
+
.hasAttribute('disabled'),
|
|
96
|
+
).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('when the email provider is off the login page', () => {
|
|
101
|
+
it('stops saying a verified address can sign you in', () => {
|
|
102
|
+
// The panel is reached from a page called "Sign-in methods". Listing
|
|
103
|
+
// something there that cannot sign anybody in is the page telling the
|
|
104
|
+
// user something untrue.
|
|
105
|
+
renderPanel({ canSignInWithLink: false });
|
|
106
|
+
|
|
107
|
+
expect(document.body.textContent).not.toContain('sign in with a link');
|
|
108
|
+
expect(document.body.textContent).toContain('recognises you');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('still offers the verification', () => {
|
|
112
|
+
// Verifying does a second job the login page has no say over: it is
|
|
113
|
+
// how a later provider login is matched to this account. Removing the
|
|
114
|
+
// button would take that away too.
|
|
115
|
+
renderPanel({ canSignInWithLink: false });
|
|
116
|
+
|
|
117
|
+
expect(document.querySelector('[data-action="verify"]')).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('says both halves when the link is a way in', () => {
|
|
121
|
+
renderPanel({ canSignInWithLink: true });
|
|
122
|
+
|
|
123
|
+
expect(document.body.textContent).toContain('sign in with a link');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('offers a verification only for an address that has none', () => {
|
|
128
|
+
renderPanel();
|
|
129
|
+
|
|
130
|
+
expect(screen.getAllByRole('button', { name: 'Verify' })).toHaveLength(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Your addresses, and the button that proves one.
|
|
3
|
+
*
|
|
4
|
+
* This is what replaced the free-text box that used to sit on this page. That
|
|
5
|
+
* box asked for an address and mailed a link to it, which verified *any*
|
|
6
|
+
* mailbox somebody could reach -- and a verified address is what a new
|
|
7
|
+
* provider account can be auto-attached to. So the addresses offered here are
|
|
8
|
+
* the ones already on your profile, and adding one is an edit to the profile
|
|
9
|
+
* rather than something this panel does.
|
|
10
|
+
*
|
|
11
|
+
* Verifying is still the same magic link and the same endpoint; what changed
|
|
12
|
+
* is that the backend now refuses an address that is not yours.
|
|
13
|
+
*
|
|
14
|
+
* **Choosing which one is yours.** A provider that knows several of your
|
|
15
|
+
* addresses puts all of them on your profile, so the question is no longer
|
|
16
|
+
* which one to keep but which one stands for you here. That is the *order* of
|
|
17
|
+
* the list -- the backend derives `email` from it, preferring the first
|
|
18
|
+
* verified address -- so {guilabel}`Make preferred` moves one to the front
|
|
19
|
+
* rather than setting a field. An unverified address can be moved and will not
|
|
20
|
+
* win while a verified one is on the list, which is why the badge is what says
|
|
21
|
+
* who won rather than what you last clicked.
|
|
22
|
+
* @module components/Identities/ProfileEmails
|
|
23
|
+
*/
|
|
24
|
+
import React from 'react';
|
|
25
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
26
|
+
import { Link } from 'react-router-dom';
|
|
27
|
+
|
|
28
|
+
import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
|
|
29
|
+
|
|
30
|
+
import type { ProfileEmail } from '../../types';
|
|
31
|
+
|
|
32
|
+
import './ProfileEmails.scss';
|
|
33
|
+
|
|
34
|
+
const messages = defineMessages({
|
|
35
|
+
heading: {
|
|
36
|
+
id: 'Your email addresses',
|
|
37
|
+
defaultMessage: 'Your email addresses',
|
|
38
|
+
},
|
|
39
|
+
intro: {
|
|
40
|
+
id: 'profile-emails-intro',
|
|
41
|
+
defaultMessage:
|
|
42
|
+
'A verified address can be used to sign in with a link, and to ' +
|
|
43
|
+
'recognise you when you add another provider. Add an address on your ' +
|
|
44
|
+
'profile first, then verify it here.',
|
|
45
|
+
},
|
|
46
|
+
introNoSignIn: {
|
|
47
|
+
id: 'profile-emails-intro-no-sign-in',
|
|
48
|
+
defaultMessage:
|
|
49
|
+
'A verified address is how this site recognises you when you add ' +
|
|
50
|
+
'another provider. Add an address on your profile first, then verify ' +
|
|
51
|
+
'it here.',
|
|
52
|
+
},
|
|
53
|
+
verify: { id: 'Verify', defaultMessage: 'Verify' },
|
|
54
|
+
makePreferred: {
|
|
55
|
+
id: 'Make preferred',
|
|
56
|
+
defaultMessage: 'Make preferred',
|
|
57
|
+
},
|
|
58
|
+
verified: { id: 'Verified', defaultMessage: 'Verified' },
|
|
59
|
+
preferred: { id: 'Preferred', defaultMessage: 'Preferred' },
|
|
60
|
+
preferredHelp: {
|
|
61
|
+
id: 'profile-emails-preferred-help',
|
|
62
|
+
defaultMessage:
|
|
63
|
+
'The address this site uses for you: the first verified one, or the ' +
|
|
64
|
+
'first on your profile when none is verified.',
|
|
65
|
+
},
|
|
66
|
+
choose: {
|
|
67
|
+
id: 'profile-emails-choose',
|
|
68
|
+
defaultMessage:
|
|
69
|
+
'Move an address to the top to make it the one this site uses for you.',
|
|
70
|
+
},
|
|
71
|
+
empty: {
|
|
72
|
+
id: 'profile-emails-empty',
|
|
73
|
+
defaultMessage: 'Your profile carries no email address yet.',
|
|
74
|
+
},
|
|
75
|
+
edit: { id: 'Edit your profile', defaultMessage: 'Edit your profile' },
|
|
76
|
+
sent: {
|
|
77
|
+
id: 'profile-emails-sent',
|
|
78
|
+
defaultMessage:
|
|
79
|
+
'Check that mailbox. The link confirms the address; it does not sign ' +
|
|
80
|
+
'anybody in.',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
interface ProfileEmailsProps {
|
|
85
|
+
emails: ProfileEmail[];
|
|
86
|
+
/** Where the profile's own edit form is, when the user has a profile. */
|
|
87
|
+
profileUrl?: string | null;
|
|
88
|
+
loading: boolean;
|
|
89
|
+
busy: boolean;
|
|
90
|
+
/** Whether a confirmation mail has just gone out. */
|
|
91
|
+
sent: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Whether signing in with a link is one of the ways in.
|
|
94
|
+
*
|
|
95
|
+
* Verifying does two jobs -- it enables the magic link, and it is how a
|
|
96
|
+
* later provider login is recognised as the same person -- and an operator
|
|
97
|
+
* can turn the first one off by taking the email provider off the login
|
|
98
|
+
* page. When they have, this panel must stop offering the half that is not
|
|
99
|
+
* there: a page called "sign-in methods" that lists something which cannot
|
|
100
|
+
* sign anybody in is telling the user something untrue.
|
|
101
|
+
*
|
|
102
|
+
* The other half survives, which is why this changes the wording rather
|
|
103
|
+
* than removing the button: recognition still works, and a user who cannot
|
|
104
|
+
* verify an address here loses the account matching too.
|
|
105
|
+
*/
|
|
106
|
+
canSignInWithLink: boolean;
|
|
107
|
+
onVerify: (address: string) => void;
|
|
108
|
+
/**
|
|
109
|
+
* Move an address to the front of the list.
|
|
110
|
+
*
|
|
111
|
+
* Optional so that a page rendering this panel read-only -- or one talking
|
|
112
|
+
* to a backend that predates the reorder -- can leave the buttons out
|
|
113
|
+
* rather than wire up a handler that does nothing.
|
|
114
|
+
*/
|
|
115
|
+
onPrefer?: (address: string) => void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ProfileEmails: React.FC<ProfileEmailsProps> = ({
|
|
119
|
+
emails,
|
|
120
|
+
profileUrl,
|
|
121
|
+
loading,
|
|
122
|
+
busy,
|
|
123
|
+
sent,
|
|
124
|
+
canSignInWithLink,
|
|
125
|
+
onVerify,
|
|
126
|
+
onPrefer,
|
|
127
|
+
}) => {
|
|
128
|
+
const intl = useIntl();
|
|
129
|
+
|
|
130
|
+
if (loading) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="identity-emails">
|
|
136
|
+
<h3>{intl.formatMessage(messages.heading)}</h3>
|
|
137
|
+
<p className="identity-note">
|
|
138
|
+
{intl.formatMessage(
|
|
139
|
+
canSignInWithLink ? messages.intro : messages.introNoSignIn,
|
|
140
|
+
)}
|
|
141
|
+
</p>
|
|
142
|
+
{onPrefer && emails.length > 1 ? (
|
|
143
|
+
<p className="identity-note">{intl.formatMessage(messages.choose)}</p>
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
{emails.length ? (
|
|
147
|
+
<ul className="identity-emails__list">
|
|
148
|
+
{emails.map((entry) => (
|
|
149
|
+
<li key={entry.address} data-address={entry.address}>
|
|
150
|
+
<span className="identity-emails__address">{entry.address}</span>
|
|
151
|
+
{entry.verified ? (
|
|
152
|
+
<span
|
|
153
|
+
className="identity-emails__badge identity-emails__badge--verified"
|
|
154
|
+
data-state="verified"
|
|
155
|
+
>
|
|
156
|
+
{intl.formatMessage(messages.verified)}
|
|
157
|
+
</span>
|
|
158
|
+
) : (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
className="identity-button"
|
|
162
|
+
data-action="verify"
|
|
163
|
+
disabled={busy}
|
|
164
|
+
onClick={() => onVerify(entry.address)}
|
|
165
|
+
>
|
|
166
|
+
{intl.formatMessage(messages.verify)}
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
{entry.preferred ? (
|
|
170
|
+
<span
|
|
171
|
+
className="identity-emails__badge"
|
|
172
|
+
data-state="preferred"
|
|
173
|
+
title={intl.formatMessage(messages.preferredHelp)}
|
|
174
|
+
>
|
|
175
|
+
{intl.formatMessage(messages.preferred)}
|
|
176
|
+
</span>
|
|
177
|
+
) : null}
|
|
178
|
+
{onPrefer && !entry.preferred ? (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className="identity-button identity-button--quiet"
|
|
182
|
+
data-action="prefer"
|
|
183
|
+
disabled={busy}
|
|
184
|
+
onClick={() => onPrefer(entry.address)}
|
|
185
|
+
>
|
|
186
|
+
{intl.formatMessage(messages.makePreferred)}
|
|
187
|
+
</button>
|
|
188
|
+
) : null}
|
|
189
|
+
</li>
|
|
190
|
+
))}
|
|
191
|
+
</ul>
|
|
192
|
+
) : (
|
|
193
|
+
<p className="identity-note">{intl.formatMessage(messages.empty)}</p>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{sent ? (
|
|
197
|
+
<p className="identity-emails__sent" role="status">
|
|
198
|
+
{intl.formatMessage(messages.sent)}
|
|
199
|
+
</p>
|
|
200
|
+
) : null}
|
|
201
|
+
|
|
202
|
+
{profileUrl ? (
|
|
203
|
+
<p className="identity-emails__edit">
|
|
204
|
+
{/* A router link: the edit form is a Volto route, and a plain
|
|
205
|
+
anchor to it threw the whole application away to reach a page
|
|
206
|
+
this app was already running. `profileUrl` is the backend's
|
|
207
|
+
absolute URL, so it has to be flattened first. */}
|
|
208
|
+
<Link to={`${flattenToAppURL(profileUrl)}/edit`}>
|
|
209
|
+
{intl.formatMessage(messages.edit)}
|
|
210
|
+
</Link>
|
|
211
|
+
</p>
|
|
212
|
+
) : null}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export default ProfileEmails;
|