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