@plone-collective/volto-identity 1.0.0-alpha.1

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