@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,63 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+
3
+ import Login from './Login';
4
+ import {
5
+ FAILED,
6
+ LOADED,
7
+ LOADING,
8
+ PROVIDERS,
9
+ withStore,
10
+ } from '../../stories/fixtures';
11
+
12
+ const meta: Meta<typeof Login> = {
13
+ title: 'Identity/Login/Login',
14
+ component: Login,
15
+ };
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof Login>;
19
+
20
+ /** The whole page: the panel, the providers, and the password fallback. */
21
+ export const Default: Story = {
22
+ decorators: [
23
+ withStore({
24
+ loginProviders: { ...LOADED, data: PROVIDERS },
25
+ providerLogin: {},
26
+ magicLinkSend: {},
27
+ userSession: { token: null, login: {} },
28
+ }),
29
+ ],
30
+ };
31
+
32
+ export const Loading: Story = {
33
+ decorators: [
34
+ withStore({
35
+ loginProviders: { ...LOADING, data: [] },
36
+ providerLogin: {},
37
+ magicLinkSend: {},
38
+ userSession: { token: null, login: {} },
39
+ }),
40
+ ],
41
+ };
42
+
43
+ export const NoProvidersConfigured: Story = {
44
+ decorators: [
45
+ withStore({
46
+ loginProviders: { ...LOADED, data: [] },
47
+ providerLogin: {},
48
+ magicLinkSend: {},
49
+ userSession: { token: null, login: {} },
50
+ }),
51
+ ],
52
+ };
53
+
54
+ export const PasswordRefused: Story = {
55
+ decorators: [
56
+ withStore({
57
+ loginProviders: { ...LOADED, data: PROVIDERS },
58
+ providerLogin: {},
59
+ magicLinkSend: {},
60
+ userSession: { token: null, login: { error: FAILED.error } },
61
+ }),
62
+ ],
63
+ };
@@ -0,0 +1,189 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { render } from '../../testing';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import { Provider } from 'react-redux';
5
+ import React from 'react';
6
+
7
+ import Login from './Login';
8
+ import * as showPloneLoginModule from '../../helpers/showPloneLogin';
9
+
10
+ /**
11
+ * Replace `window.location` so a navigation can be observed instead of
12
+ * performed; jsdom refuses to navigate and would fail the test instead.
13
+ */
14
+ function captureNavigation() {
15
+ const original = window.location;
16
+ const navigated: string[] = [];
17
+ // jsdom's location is not writable through the normal assignment.
18
+ delete (window as any).location;
19
+ // @ts-expect-error a stub with only what the component touches
20
+ window.location = {
21
+ ...original,
22
+ get href() {
23
+ return original.href;
24
+ },
25
+ set href(value: string) {
26
+ navigated.push(value);
27
+ },
28
+ };
29
+ return {
30
+ navigated,
31
+ restore: () =>
32
+ Object.defineProperty(window, 'location', {
33
+ value: original,
34
+ writable: true,
35
+ }),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * The listing slice as the reducer leaves it after a successful request.
41
+ *
42
+ * `loaded` is not decoration here: it is the flag the page uses to tell "the
43
+ * providers are not here yet" from "there are none", so a fixture without it
44
+ * is a fixture of a request that never answered.
45
+ */
46
+ const LOADED_PROVIDERS = {
47
+ loading: false,
48
+ loaded: true,
49
+ error: null,
50
+ data: [{ id: 'github', title: 'GitHub' }],
51
+ };
52
+
53
+ function storeWith(token?: string, loginProviders: any = LOADED_PROVIDERS) {
54
+ const state = {
55
+ loginProviders,
56
+ providerLogin: {},
57
+ magicLinkSend: {},
58
+ userSession: { token, login: {} },
59
+ };
60
+ return {
61
+ getState: () => state,
62
+ dispatch: (action: any) => action,
63
+ subscribe: () => () => {},
64
+ };
65
+ }
66
+
67
+ function renderLogin(
68
+ token?: string,
69
+ search = '?came_from=%2F%40%40oauth-authorize',
70
+ loginProviders?: any,
71
+ ) {
72
+ render(
73
+ <Provider store={storeWith(token, loginProviders) as any}>
74
+ <MemoryRouter initialEntries={[`/login${search}`]}>
75
+ <Login />
76
+ </MemoryRouter>
77
+ </Provider>,
78
+ );
79
+ }
80
+
81
+ describe('Login', () => {
82
+ let capture: ReturnType<typeof captureNavigation>;
83
+
84
+ beforeEach(() => {
85
+ capture = captureNavigation();
86
+ });
87
+
88
+ afterEach(() => {
89
+ capture.restore();
90
+ vi.restoreAllMocks();
91
+ });
92
+
93
+ it('does not redirect when the visitor already had a session', () => {
94
+ // This page is only reached because something refused that session --
95
+ // an authorization endpoint that will not accept the principal. Bouncing
96
+ // back to it is an infinite redirect, and the sign-in buttons never stay
97
+ // on screen long enough to be clicked.
98
+ renderLogin('an-existing-token');
99
+
100
+ expect(capture.navigated).toEqual([]);
101
+ });
102
+
103
+ it('shows the providers instead of redirecting', () => {
104
+ renderLogin('an-existing-token');
105
+
106
+ expect(document.body.textContent).toContain('GitHub');
107
+ });
108
+
109
+ it('asks at render time whether to offer the password form', () => {
110
+ // Not read from the settings at install: the answer arrives in the
111
+ // container's environment, so one image serves a site that wants the
112
+ // password form and one that does not. Severing this wiring is invisible
113
+ // to every other test here, which is why it has one of its own.
114
+ const decide = vi
115
+ .spyOn(showPloneLoginModule, 'showPloneLogin')
116
+ .mockReturnValue(true);
117
+
118
+ renderLogin('an-existing-token');
119
+
120
+ expect(decide).toHaveBeenCalled();
121
+ expect(document.body.textContent).toContain('Sign in with a password');
122
+ });
123
+
124
+ it('hides the password form when the environment says to', () => {
125
+ vi.spyOn(showPloneLoginModule, 'showPloneLogin').mockReturnValue(false);
126
+
127
+ renderLogin('an-existing-token');
128
+
129
+ expect(document.body.textContent).not.toContain('Sign in with a password');
130
+ });
131
+
132
+ it('does not redirect an anonymous visitor either', () => {
133
+ renderLogin(undefined);
134
+
135
+ expect(capture.navigated).toEqual([]);
136
+ });
137
+
138
+ it('waits rather than guessing before the listing has answered', () => {
139
+ // The slice as it is on the very first render: the effect has not
140
+ // dispatched yet, so it is neither loading nor loaded. Reading only
141
+ // `loading` and `data` made that indistinguishable from a site with no
142
+ // providers, and the page rendered the local password form -- the
143
+ // no-providers fallback -- for a tick before replacing it.
144
+ renderLogin(undefined, undefined, {
145
+ loading: false,
146
+ loaded: false,
147
+ error: null,
148
+ data: [],
149
+ });
150
+
151
+ expect(document.body.textContent).toContain('Loading');
152
+ expect(document.body.textContent).not.toContain('Sign in with a password');
153
+ expect(document.querySelector('#login-form-submit')).toBeNull();
154
+ });
155
+
156
+ it('names what is below only once it knows', () => {
157
+ // The description strip is the same guess in prose. Naming the local
158
+ // password form and then replacing the sentence is the flicker with
159
+ // words instead of fields.
160
+ renderLogin(undefined, undefined, {
161
+ loading: true,
162
+ loaded: false,
163
+ error: null,
164
+ data: [],
165
+ });
166
+
167
+ expect(document.body.textContent).not.toContain(
168
+ 'Sign in with your account on this site.',
169
+ );
170
+ expect(document.body.textContent).not.toContain(
171
+ 'Choose how you would like to sign in.',
172
+ );
173
+ });
174
+
175
+ it('falls back to the password form when the listing fails', () => {
176
+ // A failure has answered -- with nothing. Holding the loading state for
177
+ // it would leave a site whose provider list is unreachable with a
178
+ // spinner instead of the one way in that still works.
179
+ renderLogin(undefined, undefined, {
180
+ loading: false,
181
+ loaded: false,
182
+ error: { status: 500 },
183
+ data: [],
184
+ });
185
+
186
+ expect(document.body.textContent).not.toContain('Loading');
187
+ expect(document.querySelector('#login-form-submit')).toBeTruthy();
188
+ });
189
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Login container: store, routing, and the redirect out to the provider.
3
+ * @module components/Login/Login
4
+ */
5
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
6
+ import { useDispatch, useSelector } from 'react-redux';
7
+ import { useHistory, useLocation } from 'react-router-dom';
8
+ import { defineMessages, useIntl } from 'react-intl';
9
+
10
+ import {
11
+ listLoginProviders,
12
+ sendMagicLink,
13
+ startProviderLogin,
14
+ } from '../../actions';
15
+ import { login } from '@plone/volto/actions/userSession/userSession';
16
+
17
+ import { returnUrl } from '../../helpers/returnUrl';
18
+ import { showPloneLogin } from '../../helpers/showPloneLogin';
19
+ import type { LoginProvider } from '../../types';
20
+ import LoginForm from './LoginForm';
21
+ import LoginPanel from './LoginPanel';
22
+ import { goTo } from '../../helpers/navigate';
23
+
24
+ const messages = defineMessages({
25
+ title: { id: 'Log in', defaultMessage: 'Log in' },
26
+ chooseHow: {
27
+ id: 'Choose how you would like to sign in.',
28
+ defaultMessage: 'Choose how you would like to sign in.',
29
+ },
30
+ signInLocally: {
31
+ id: 'Sign in with your account on this site.',
32
+ defaultMessage: 'Sign in with your account on this site.',
33
+ },
34
+ });
35
+
36
+ const Login: React.FC = () => {
37
+ const intl = useIntl();
38
+ const dispatch = useDispatch();
39
+ const location = useLocation();
40
+ const { push } = useHistory();
41
+ const [redirecting, setRedirecting] = useState(false);
42
+ // The session as it was when this page loaded. Only a token that appears
43
+ // *after* that is somebody signing in here.
44
+ const sessionOnArrival = useRef<string | undefined>(undefined);
45
+
46
+ const providers = useSelector((state: any) => state.loginProviders);
47
+ const started = useSelector((state: any) => state.providerLogin);
48
+ const magic = useSelector((state: any) => state.magicLinkSend);
49
+ const userSession = useSelector((state: any) => state.userSession);
50
+
51
+ useEffect(() => {
52
+ dispatch(listLoginProviders());
53
+ }, [dispatch]);
54
+
55
+ useEffect(() => {
56
+ if (redirecting && started?.loaded && started?.data?.authorize_url) {
57
+ // A full page load, not a router push: the next stop is the provider's
58
+ // own origin.
59
+ goTo(started.data.authorize_url, push, { external: true });
60
+ }
61
+ }, [redirecting, started, push]);
62
+
63
+ const onSelectProvider = useCallback(
64
+ (provider: LoginProvider) => {
65
+ setRedirecting(true);
66
+ dispatch(
67
+ startProviderLogin(
68
+ provider.id,
69
+ returnUrl(location.search, location.pathname),
70
+ ),
71
+ );
72
+ },
73
+ [dispatch, location.search, location.pathname],
74
+ );
75
+
76
+ const onPasswordLogin = useCallback(
77
+ (username: string, password: string) => {
78
+ dispatch(login(username, password));
79
+ },
80
+ [dispatch],
81
+ );
82
+
83
+ useEffect(() => {
84
+ if (sessionOnArrival.current === undefined) {
85
+ sessionOnArrival.current = userSession?.token ?? '';
86
+ }
87
+ }, [userSession?.token]);
88
+
89
+ // Volto stores the token and its own AppExtras redirects; all this has to
90
+ // do is get the user back to where the flow started, which for an
91
+ // authorization request is the whole request.
92
+ //
93
+ // Only for a token that appeared while this page was open. Arriving here
94
+ // *already* carrying one means whatever sent us here would not accept it,
95
+ // because this page is only reached when something refused the session --
96
+ // so bouncing straight back is an infinite redirect, and the sign-in
97
+ // options never stay on screen long enough to be clicked. Showing the form
98
+ // instead lets the visitor sign in as somebody the flow will accept.
99
+ useEffect(() => {
100
+ const token = userSession?.token;
101
+ const arrived = sessionOnArrival.current;
102
+ if (token && arrived !== undefined && token !== arrived) {
103
+ goTo(returnUrl(location.search, location.pathname), push);
104
+ }
105
+ }, [userSession?.token, location.search, location.pathname, push]);
106
+
107
+ const onSendMagicLink = useCallback(
108
+ (email: string) => {
109
+ dispatch(sendMagicLink(email));
110
+ },
111
+ [dispatch],
112
+ );
113
+
114
+ // Whether the listing has answered at all -- which is not the same thing as
115
+ // `loading`. The slice starts neither loading nor loaded, so between the
116
+ // first render and the effect below dispatching, `loading` is false and
117
+ // `data` is empty: indistinguishable, to anything reading those two, from a
118
+ // site with no providers configured. That is the state the page used to
119
+ // render the local password form in, for a tick, before replacing it.
120
+ //
121
+ // A failed listing is deliberately *not* held here. It has answered -- with
122
+ // nothing -- and the form's own fallbacks are what a site with no reachable
123
+ // provider list should get: the password form is the way in when this page
124
+ // cannot draw the other ones.
125
+ const answered = Boolean(providers?.loaded || providers?.error);
126
+
127
+ // The description names what is actually below, which differs by site: a
128
+ // provider list, a password form, or both. Until the listing answers there
129
+ // is nothing below to name, so the strip is left out rather than guessing
130
+ // -- guessing means the wrong sentence on screen and then a second one
131
+ // replacing it.
132
+ const hasProviders = Boolean(providers?.data?.length);
133
+ const description = answered
134
+ ? intl.formatMessage(
135
+ hasProviders ? messages.chooseHow : messages.signInLocally,
136
+ )
137
+ : undefined;
138
+
139
+ return (
140
+ <LoginPanel
141
+ title={intl.formatMessage(messages.title)}
142
+ description={description}
143
+ >
144
+ <LoginForm
145
+ providers={providers?.data ?? []}
146
+ loading={!answered}
147
+ starting={redirecting}
148
+ error={started?.error}
149
+ magicLinkSent={Boolean(magic?.data)}
150
+ magicLinkLoading={Boolean(magic?.loading)}
151
+ magicLinkError={magic?.error}
152
+ onSelectProvider={onSelectProvider}
153
+ onSendMagicLink={onSendMagicLink}
154
+ passwordLoading={Boolean(userSession?.login?.loading)}
155
+ passwordError={userSession?.login?.error}
156
+ showPloneLogin={showPloneLogin()}
157
+ onPasswordLogin={onPasswordLogin}
158
+ />
159
+ </LoginPanel>
160
+ );
161
+ };
162
+
163
+ export default Login;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The stack of ways in: provider buttons, the magic link, the password form.
3
+ *
4
+ * Each is its own component with its own stylesheet; this only spaces them
5
+ * apart and centres the provider list.
6
+ */
7
+ .identity-login {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: var(--identity-gap);
11
+ }
12
+
13
+ /* The control that swaps between the providers and the password form. Plain
14
+ text rather than a button: it chooses between two ways in, and neither of
15
+ them is the page's action. */
16
+ .identity-password-toggle {
17
+ padding: 10px;
18
+ text-align: center;
19
+
20
+ > button {
21
+ padding: 0;
22
+ border: unset;
23
+ background: transparent;
24
+ color: var(--identity-accent);
25
+ cursor: pointer;
26
+ font-size: 14px;
27
+ }
28
+ }
29
+
30
+ .identity-providers {
31
+ display: flex;
32
+ flex-direction: column;
33
+ align-items: center;
34
+ padding: 0 0 1rem;
35
+ margin: 1rem;
36
+ gap: 6px;
37
+ list-style: none;
38
+ text-align: center;
39
+ }
@@ -0,0 +1,112 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+
3
+ import LoginForm from './LoginForm';
4
+ import {
5
+ EMAIL,
6
+ FAILED,
7
+ PROVIDERS,
8
+ withLoginCard,
9
+ } from '../../stories/fixtures';
10
+
11
+ const meta: Meta<typeof LoginForm> = {
12
+ title: 'Identity/Login/LoginForm',
13
+ component: LoginForm,
14
+ decorators: [withLoginCard()],
15
+ args: {
16
+ providers: PROVIDERS,
17
+ loading: false,
18
+ starting: false,
19
+ magicLinkSent: false,
20
+ magicLinkLoading: false,
21
+ passwordLoading: false,
22
+ showPloneLogin: true,
23
+ onSelectProvider: () => {},
24
+ onSendMagicLink: () => {},
25
+ onPasswordLogin: () => {},
26
+ },
27
+ };
28
+ export default meta;
29
+
30
+ type Story = StoryObj<typeof LoginForm>;
31
+
32
+ export const Providers: Story = {};
33
+
34
+ /**
35
+ * Email as one of the buttons.
36
+ *
37
+ * It is not a redirect, so pressing it opens its field on the next step
38
+ * rather than leaving this origin -- but on the picker it is a way in like
39
+ * any other, which is the only thing the picker is about.
40
+ */
41
+ export const WithMagicLink: Story = {
42
+ args: { providers: [...PROVIDERS, EMAIL] },
43
+ };
44
+
45
+ export const Loading: Story = { args: { loading: true } };
46
+
47
+ /**
48
+ * A site can legitimately have none configured, and an authorization server
49
+ * never will: its users are local. The password has to be offered here, or
50
+ * there is no way in at all.
51
+ */
52
+ export const NoProviders: Story = { args: { providers: [] } };
53
+
54
+ /** A site that has providers and wants no password beside them. */
55
+ export const ProvidersOnly: Story = {
56
+ args: { showPloneLogin: false },
57
+ };
58
+
59
+ /**
60
+ * One provider and nothing else: the picker is skipped and the flow starts
61
+ * on its own, so what is on screen is where it is going.
62
+ */
63
+ export const TheOnlyWayIn: Story = {
64
+ args: { showPloneLogin: false, providers: [PROVIDERS[0]] },
65
+ };
66
+
67
+ /** The same site, after that provider turned out to be unreachable. */
68
+ export const TheOnlyWayInFailed: Story = {
69
+ args: {
70
+ showPloneLogin: false,
71
+ providers: [PROVIDERS[0]],
72
+ error: FAILED.error,
73
+ },
74
+ };
75
+
76
+ /**
77
+ * A site whose only way in is a link in the post.
78
+ *
79
+ * Also what the Email button opens on a site with a choice: one way in is
80
+ * not a choice, so the step it would lead to is the page itself. The step
81
+ * reached by pressing the button is an interaction rather than a state, so
82
+ * it is pinned in `LoginForm.test.tsx` rather than here.
83
+ */
84
+ export const MagicLinkOnly: Story = {
85
+ args: { showPloneLogin: false, providers: [EMAIL] },
86
+ };
87
+
88
+ export const Redirecting: Story = { args: { starting: true } };
89
+
90
+ export const StartFailed: Story = { args: { error: FAILED.error } };
91
+
92
+ /**
93
+ * A refused password, on the page that shows it.
94
+ *
95
+ * `passwordError` reaches `PasswordForm`, and that form is only on screen
96
+ * once it has been opened -- so with providers configured this story rendered
97
+ * the picker and showed nothing at all. A site with no providers *is* the
98
+ * password form, which is the state worth looking at.
99
+ */
100
+ export const PasswordRefused: Story = {
101
+ args: { providers: [], passwordError: FAILED.error },
102
+ };
103
+
104
+ /** The same form while the attempt is still in flight. */
105
+ export const Authenticating: Story = {
106
+ args: { providers: [], passwordLoading: true },
107
+ };
108
+
109
+ /** A refusal from the provider listing, over the options it could not load. */
110
+ export const StartFailedDismissible: Story = {
111
+ args: { error: FAILED.error },
112
+ };