@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,483 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
3
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import LoginForm from './LoginForm';
|
|
7
|
+
import type { LoginProvider } from '../../types';
|
|
8
|
+
|
|
9
|
+
const DEX: LoginProvider = {
|
|
10
|
+
'@id': '/@login-providers/dex',
|
|
11
|
+
id: 'dex',
|
|
12
|
+
title: 'Dex',
|
|
13
|
+
driver: 'oidc-generic',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const EMAIL: LoginProvider = {
|
|
17
|
+
'@id': '/@login-providers/email',
|
|
18
|
+
id: 'email',
|
|
19
|
+
title: 'Email',
|
|
20
|
+
driver: 'email',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function renderForm(
|
|
24
|
+
props: Partial<React.ComponentProps<typeof LoginForm>> = {},
|
|
25
|
+
) {
|
|
26
|
+
const onSelectProvider = vi.fn();
|
|
27
|
+
const onSendMagicLink = vi.fn();
|
|
28
|
+
const onPasswordLogin = vi.fn();
|
|
29
|
+
const tree = (extra: Partial<React.ComponentProps<typeof LoginForm>>) => (
|
|
30
|
+
// The password form links to /passwordreset, the way volto-authomatic's
|
|
31
|
+
// does, so it needs a router.
|
|
32
|
+
<MemoryRouter>
|
|
33
|
+
<LoginForm
|
|
34
|
+
providers={[DEX]}
|
|
35
|
+
loading={false}
|
|
36
|
+
starting={false}
|
|
37
|
+
magicLinkSent={false}
|
|
38
|
+
magicLinkLoading={false}
|
|
39
|
+
passwordLoading={false}
|
|
40
|
+
// The default here, not the product's: most of these are about the
|
|
41
|
+
// picker, and a picker needs something to pick between.
|
|
42
|
+
showPloneLogin
|
|
43
|
+
onSelectProvider={onSelectProvider}
|
|
44
|
+
onSendMagicLink={onSendMagicLink}
|
|
45
|
+
onPasswordLogin={onPasswordLogin}
|
|
46
|
+
{...props}
|
|
47
|
+
{...extra}
|
|
48
|
+
/>
|
|
49
|
+
</MemoryRouter>
|
|
50
|
+
);
|
|
51
|
+
const result = render(tree({}));
|
|
52
|
+
return {
|
|
53
|
+
onSelectProvider,
|
|
54
|
+
onSendMagicLink,
|
|
55
|
+
onPasswordLogin,
|
|
56
|
+
// Re-render with the same component instance, so state a click put there
|
|
57
|
+
// -- which provider was chosen -- survives the container answering.
|
|
58
|
+
update: (extra: Partial<React.ComponentProps<typeof LoginForm>>) =>
|
|
59
|
+
result.rerender(tree(extra)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reach the magic-link form the way a visitor does, from the picker.
|
|
65
|
+
*
|
|
66
|
+
* Only needed where the picker is on screen: with email as the single way
|
|
67
|
+
* in, the form is the page and there is no button to press.
|
|
68
|
+
*/
|
|
69
|
+
function openEmail() {
|
|
70
|
+
fireEvent.click(screen.getByText('Email'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('LoginForm', () => {
|
|
74
|
+
it('renders a button per provider', () => {
|
|
75
|
+
renderForm({ providers: [DEX, { ...DEX, id: 'github', title: 'GitHub' }] });
|
|
76
|
+
|
|
77
|
+
// Three buttons, not two: the local password is offered as one of these
|
|
78
|
+
// as well, so the list carries one more than there are providers.
|
|
79
|
+
const list = document.querySelector('.identity-providers');
|
|
80
|
+
expect(list?.querySelectorAll('button')).toHaveLength(3);
|
|
81
|
+
expect(screen.getByText('Dex')).toBeTruthy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reports the chosen provider', () => {
|
|
85
|
+
const { onSelectProvider } = renderForm();
|
|
86
|
+
|
|
87
|
+
fireEvent.click(screen.getByText('Dex'));
|
|
88
|
+
|
|
89
|
+
expect(onSelectProvider).toHaveBeenCalledWith(DEX);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('disables the buttons once a redirect is under way', () => {
|
|
93
|
+
renderForm({ starting: true });
|
|
94
|
+
|
|
95
|
+
// Otherwise an impatient second click starts a second flow, whose state
|
|
96
|
+
// replaces the first one's and strands the redirect already in flight.
|
|
97
|
+
expect(screen.getByText('Dex').closest('button')?.disabled).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('says so while the options are loading', () => {
|
|
101
|
+
renderForm({ loading: true });
|
|
102
|
+
|
|
103
|
+
expect(screen.getByRole('status').textContent).toContain('Loading');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('draws the wait as well as describing it', () => {
|
|
107
|
+
renderForm({ loading: true });
|
|
108
|
+
|
|
109
|
+
// Decorative: the text beside it is what a screen reader reads, which is
|
|
110
|
+
// why the spinner is hidden from one rather than labelled twice.
|
|
111
|
+
const overlay = document.querySelector('.identity-overlay--waiting');
|
|
112
|
+
expect(overlay).toBeTruthy();
|
|
113
|
+
const spinner = overlay?.querySelector('.identity-spinner');
|
|
114
|
+
expect(spinner).toBeTruthy();
|
|
115
|
+
expect(spinner?.getAttribute('aria-hidden')).toBe('true');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('draws the redirect the same way', () => {
|
|
119
|
+
// It is the same kind of wait -- something happening this page cannot
|
|
120
|
+
// show -- and used to be the one state rendered as bare body text.
|
|
121
|
+
renderForm({ showPloneLogin: false, providers: [DEX] });
|
|
122
|
+
|
|
123
|
+
const overlay = document.querySelector('.identity-overlay--waiting');
|
|
124
|
+
expect(overlay).toBeTruthy();
|
|
125
|
+
expect(overlay?.querySelector('.identity-spinner')).toBeTruthy();
|
|
126
|
+
expect(overlay?.textContent).toContain('Dex');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('says where it is going while the options stay put', () => {
|
|
130
|
+
// Greying the buttons says they cannot be pressed, not that anything is
|
|
131
|
+
// happening or where it is going. Both used to be true only on the
|
|
132
|
+
// single-provider page.
|
|
133
|
+
renderForm({ providers: [DEX, EMAIL] });
|
|
134
|
+
fireEvent.click(screen.getByText('Dex'));
|
|
135
|
+
|
|
136
|
+
// `starting` is the container's answer, so it is passed rather than
|
|
137
|
+
// observed -- what this pins is that the picker is still underneath.
|
|
138
|
+
expect(document.querySelector('.identity-providers')).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('names the provider it is redirecting to', () => {
|
|
142
|
+
const { update } = renderForm({ providers: [DEX, EMAIL] });
|
|
143
|
+
fireEvent.click(screen.getByText('Dex'));
|
|
144
|
+
update({ starting: true });
|
|
145
|
+
|
|
146
|
+
const overlay = document.querySelector('.identity-overlay--waiting');
|
|
147
|
+
expect(overlay?.textContent).toContain('Dex');
|
|
148
|
+
expect(document.querySelector('.identity-providers')).toBeTruthy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('lets a refusal be dismissed so the options can be used again', () => {
|
|
152
|
+
// Left up, it covers the buttons the reader would press to try again.
|
|
153
|
+
renderForm({ error: { status: 502 } });
|
|
154
|
+
|
|
155
|
+
expect(screen.getByRole('alert')).toBeTruthy();
|
|
156
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
157
|
+
|
|
158
|
+
expect(document.querySelector('.identity-overlay--error')).toBeNull();
|
|
159
|
+
expect(screen.getByText('Dex')).toBeTruthy();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('is just the password form on a site with no providers', () => {
|
|
163
|
+
// This page replaces Volto's login, so hiding the password here left a
|
|
164
|
+
// site with local accounts and no provider -- an authorization server,
|
|
165
|
+
// for instance -- with no way in at all. With nothing to choose between,
|
|
166
|
+
// the disclosure is one click between the visitor and the only form
|
|
167
|
+
// there is, so the fields are on screen straight away.
|
|
168
|
+
renderForm({ providers: [] });
|
|
169
|
+
|
|
170
|
+
expect(screen.queryByText('Sign in with a password')).toBeNull();
|
|
171
|
+
expect(screen.getByLabelText('Login name')).toBeTruthy();
|
|
172
|
+
expect(screen.getByLabelText('Password')).toBeTruthy();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('does not announce the absence of providers', () => {
|
|
176
|
+
// The panel's description already says the account is one on this site;
|
|
177
|
+
// saying "none are configured" on top of that reads as an error over a
|
|
178
|
+
// form that works.
|
|
179
|
+
renderForm({ providers: [] });
|
|
180
|
+
|
|
181
|
+
expect(screen.queryByText(/No sign-in providers/)).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('offers the password as one of the buttons, not a line of text', () => {
|
|
185
|
+
renderForm();
|
|
186
|
+
|
|
187
|
+
// The label is a span inside the button now, so that an operator's icon
|
|
188
|
+
// can sit beside it.
|
|
189
|
+
const button = screen
|
|
190
|
+
.getByText('Sign in with a password')
|
|
191
|
+
.closest('button') as HTMLButtonElement;
|
|
192
|
+
expect(button.closest('.identity-providers')).toBeTruthy();
|
|
193
|
+
// volto-authomatic's `plone` colours, so the same button is the same
|
|
194
|
+
// blue in both add-ons.
|
|
195
|
+
expect(button.className).toContain('identity-provider--plone');
|
|
196
|
+
expect(screen.queryByLabelText('Login name')).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('replaces the providers with the password form, not adds to it', () => {
|
|
200
|
+
// The password is a different way to sign in, not an extra field on the
|
|
201
|
+
// provider list; leaving the buttons above it made the page read as one
|
|
202
|
+
// form with a stray row of buttons on top.
|
|
203
|
+
renderForm({ providers: [DEX, EMAIL] });
|
|
204
|
+
|
|
205
|
+
fireEvent.click(screen.getByText('Sign in with a password'));
|
|
206
|
+
|
|
207
|
+
expect(document.querySelector('.identity-providers')).toBeNull();
|
|
208
|
+
expect(screen.queryByLabelText('Email address')).toBeNull();
|
|
209
|
+
expect(screen.getByLabelText('Login name')).toBeTruthy();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('offers a way back to them', () => {
|
|
213
|
+
// Hiding the providers with no way back strands anybody who opened the
|
|
214
|
+
// password form by mistake on the one form they cannot use.
|
|
215
|
+
renderForm({ providers: [DEX, EMAIL] });
|
|
216
|
+
fireEvent.click(screen.getByText('Sign in with a password'));
|
|
217
|
+
|
|
218
|
+
fireEvent.click(screen.getByText('Back to sign-in options'));
|
|
219
|
+
|
|
220
|
+
expect(screen.getByText('Dex')).toBeTruthy();
|
|
221
|
+
expect(screen.getByText('Email')).toBeTruthy();
|
|
222
|
+
expect(screen.queryByLabelText('Login name')).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('offers a way back out of the email form too', () => {
|
|
226
|
+
// The same escape, from the step that replaced the inline form.
|
|
227
|
+
renderForm({ providers: [DEX, EMAIL] });
|
|
228
|
+
fireEvent.click(screen.getByText('Email'));
|
|
229
|
+
|
|
230
|
+
fireEvent.click(screen.getByText('Back to sign-in options'));
|
|
231
|
+
|
|
232
|
+
expect(screen.getByText('Dex')).toBeTruthy();
|
|
233
|
+
expect(screen.queryByLabelText('Email address')).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('offers a password alongside the providers', () => {
|
|
237
|
+
renderForm();
|
|
238
|
+
|
|
239
|
+
expect(screen.getByText('Sign in with a password')).toBeTruthy();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('submits the credentials that were typed', () => {
|
|
243
|
+
const { onPasswordLogin } = renderForm();
|
|
244
|
+
fireEvent.click(screen.getByText('Sign in with a password'));
|
|
245
|
+
|
|
246
|
+
fireEvent.change(screen.getByLabelText('Login name'), {
|
|
247
|
+
target: { value: 'alice' },
|
|
248
|
+
});
|
|
249
|
+
fireEvent.change(screen.getByLabelText('Password'), {
|
|
250
|
+
target: { value: 'hunter2' },
|
|
251
|
+
});
|
|
252
|
+
// An icon button since the form took volto-authomatic's shape, so it is
|
|
253
|
+
// found by its accessible name rather than its text.
|
|
254
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
|
255
|
+
|
|
256
|
+
expect(onPasswordLogin).toHaveBeenCalledWith('alice', 'hunter2');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('does not distinguish a wrong name from a wrong password', () => {
|
|
260
|
+
// Telling them apart is an account-enumeration oracle.
|
|
261
|
+
renderForm({ passwordError: { status: 401 } });
|
|
262
|
+
fireEvent.click(screen.getByText('Sign in with a password'));
|
|
263
|
+
|
|
264
|
+
expect(screen.getByRole('alert').textContent).toContain('did not match');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('reports a failed start', () => {
|
|
268
|
+
renderForm({ error: { status: 502 } });
|
|
269
|
+
|
|
270
|
+
expect(screen.getByRole('alert').textContent).toContain('not available');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('offers no password button when the site turned it off', () => {
|
|
274
|
+
renderForm({ showPloneLogin: false, providers: [DEX, EMAIL] });
|
|
275
|
+
|
|
276
|
+
expect(screen.queryByText('Sign in with a password')).toBeNull();
|
|
277
|
+
expect(screen.getByText('Dex')).toBeTruthy();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('still offers the password when nothing else is configured', () => {
|
|
281
|
+
// The setting decides whether a password sits *beside* the providers. A
|
|
282
|
+
// fresh install has the add-on on and no provider yet, and must not be a
|
|
283
|
+
// site with no way in at all.
|
|
284
|
+
renderForm({ showPloneLogin: false, providers: [] });
|
|
285
|
+
|
|
286
|
+
expect(screen.getByLabelText(/Login Name/i)).toBeTruthy();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('goes straight to the only provider there is', () => {
|
|
290
|
+
// One button asking somebody to confirm the only thing that can happen
|
|
291
|
+
// is a click that carries no decision.
|
|
292
|
+
const { onSelectProvider } = renderForm({
|
|
293
|
+
showPloneLogin: false,
|
|
294
|
+
providers: [DEX],
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(onSelectProvider).toHaveBeenCalledWith(DEX);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('says where it is taking them rather than flashing a button', () => {
|
|
301
|
+
renderForm({ showPloneLogin: false, providers: [DEX] });
|
|
302
|
+
|
|
303
|
+
expect(screen.getByRole('status').textContent).toContain('Dex');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('does not redirect when there is a choice to make', () => {
|
|
307
|
+
const { onSelectProvider } = renderForm({
|
|
308
|
+
showPloneLogin: false,
|
|
309
|
+
providers: [DEX, { ...DEX, id: 'github', title: 'GitHub' }],
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(onSelectProvider).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('does not redirect past a provider that just failed', () => {
|
|
316
|
+
// Otherwise an unreachable provider is an unbreakable loop: start, fail,
|
|
317
|
+
// render, start again.
|
|
318
|
+
const { onSelectProvider } = renderForm({
|
|
319
|
+
showPloneLogin: false,
|
|
320
|
+
providers: [DEX],
|
|
321
|
+
error: new Error('nope'),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(onSelectProvider).not.toHaveBeenCalled();
|
|
325
|
+
expect(screen.getByRole('alert')).toBeTruthy();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('is just the magic-link form when that is the only way in', () => {
|
|
329
|
+
renderForm({ showPloneLogin: false, providers: [EMAIL] });
|
|
330
|
+
|
|
331
|
+
expect(screen.queryByText('Sign in with a password')).toBeNull();
|
|
332
|
+
expect(document.querySelector('.identity-providers')).toBeNull();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('offers the email provider as a button, not an open field', () => {
|
|
336
|
+
renderForm({ providers: [DEX, EMAIL] });
|
|
337
|
+
|
|
338
|
+
// It was the one way in that did not look like one: a labelled input
|
|
339
|
+
// standing open under a page asking which method to use.
|
|
340
|
+
const list = document.querySelector('.identity-providers');
|
|
341
|
+
expect(list?.textContent).toContain('Email');
|
|
342
|
+
expect(screen.queryByLabelText('Email address')).toBeNull();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('opens the email field on the next step', () => {
|
|
346
|
+
renderForm({ providers: [DEX, EMAIL] });
|
|
347
|
+
|
|
348
|
+
fireEvent.click(screen.getByText('Email'));
|
|
349
|
+
|
|
350
|
+
// The same second step the password button already had: the picker is
|
|
351
|
+
// replaced rather than added to.
|
|
352
|
+
expect(document.querySelector('.identity-providers')).toBeNull();
|
|
353
|
+
expect(screen.getByLabelText('Email address')).toBeTruthy();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('does not redirect when the email button is pressed', () => {
|
|
357
|
+
// It answers with a message rather than an authorize URL, so treating it
|
|
358
|
+
// like the others would start a flow that cannot finish.
|
|
359
|
+
const { onSelectProvider } = renderForm({ providers: [DEX, EMAIL] });
|
|
360
|
+
|
|
361
|
+
fireEvent.click(screen.getByText('Email'));
|
|
362
|
+
|
|
363
|
+
expect(onSelectProvider).not.toHaveBeenCalled();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('closes the password form when the email one opens', () => {
|
|
367
|
+
// One disclosure, not one boolean each: two open forms is not a state
|
|
368
|
+
// this page has.
|
|
369
|
+
renderForm({ providers: [DEX, EMAIL] });
|
|
370
|
+
fireEvent.click(screen.getByText('Sign in with a password'));
|
|
371
|
+
fireEvent.click(screen.getByText('Back to sign-in options'));
|
|
372
|
+
|
|
373
|
+
fireEvent.click(screen.getByText('Email'));
|
|
374
|
+
|
|
375
|
+
expect(screen.queryByLabelText('Login name')).toBeNull();
|
|
376
|
+
expect(screen.getByLabelText('Email address')).toBeTruthy();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('offers no magic-link form when no email provider is configured', () => {
|
|
380
|
+
renderForm({ providers: [DEX] });
|
|
381
|
+
|
|
382
|
+
expect(screen.queryByLabelText('Email address')).toBeNull();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe('MagicLinkForm', () => {
|
|
387
|
+
it('sends the address typed in', () => {
|
|
388
|
+
const { onSendMagicLink } = renderForm({ providers: [EMAIL] });
|
|
389
|
+
openEmail();
|
|
390
|
+
|
|
391
|
+
fireEvent.change(screen.getByLabelText('Email address'), {
|
|
392
|
+
target: { value: 'erico@plone.org' },
|
|
393
|
+
});
|
|
394
|
+
// An icon button since the form took the password form's shape, so it
|
|
395
|
+
// is found by its accessible name rather than its text.
|
|
396
|
+
fireEvent.click(screen.getByRole('button', { name: 'Email me a link' }));
|
|
397
|
+
|
|
398
|
+
expect(onSendMagicLink).toHaveBeenCalledWith('erico@plone.org');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('will not send an empty address', () => {
|
|
402
|
+
const { onSendMagicLink } = renderForm({ providers: [EMAIL] });
|
|
403
|
+
openEmail();
|
|
404
|
+
|
|
405
|
+
// An icon button since the form took the password form's shape, so it
|
|
406
|
+
// is found by its accessible name rather than its text.
|
|
407
|
+
fireEvent.click(screen.getByRole('button', { name: 'Email me a link' }));
|
|
408
|
+
|
|
409
|
+
expect(onSendMagicLink).not.toHaveBeenCalled();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('trims what was typed', () => {
|
|
413
|
+
const { onSendMagicLink } = renderForm({ providers: [EMAIL] });
|
|
414
|
+
openEmail();
|
|
415
|
+
|
|
416
|
+
fireEvent.change(screen.getByLabelText('Email address'), {
|
|
417
|
+
target: { value: ' erico@plone.org ' },
|
|
418
|
+
});
|
|
419
|
+
// An icon button since the form took the password form's shape, so it
|
|
420
|
+
// is found by its accessible name rather than its text.
|
|
421
|
+
fireEvent.click(screen.getByRole('button', { name: 'Email me a link' }));
|
|
422
|
+
|
|
423
|
+
expect(onSendMagicLink).toHaveBeenCalledWith('erico@plone.org');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('does not say whether the address is known', () => {
|
|
427
|
+
renderForm({ providers: [EMAIL], magicLinkSent: true });
|
|
428
|
+
openEmail();
|
|
429
|
+
|
|
430
|
+
// The backend answers identically for known and unknown addresses; a UI
|
|
431
|
+
// that distinguished them would undo that.
|
|
432
|
+
const message = screen.getByRole('status').textContent ?? '';
|
|
433
|
+
expect(message).toContain('If that address');
|
|
434
|
+
expect(screen.queryByLabelText('Email address')).toBeNull();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe('MagicLinkForm shape', () => {
|
|
439
|
+
it('is the same form the password one is', () => {
|
|
440
|
+
// A bare label and input beside a fully dressed PloneAuth, on the same
|
|
441
|
+
// card, one click apart: the two forms on this page ask for one and two
|
|
442
|
+
// fields and are otherwise the same form.
|
|
443
|
+
renderForm({ providers: [DEX, EMAIL] });
|
|
444
|
+
openEmail();
|
|
445
|
+
|
|
446
|
+
const form = document.querySelector('form.PloneAuth.identity-magic-link');
|
|
447
|
+
expect(form).toBeTruthy();
|
|
448
|
+
expect(form?.querySelector('.form .react-aria-TextField')).toBeTruthy();
|
|
449
|
+
expect(
|
|
450
|
+
form?.querySelector('.actions #magic-link-form-submit'),
|
|
451
|
+
).toBeTruthy();
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe('PasswordForm shape', () => {
|
|
456
|
+
it('uses the same markup the standard Volto login form does', () => {
|
|
457
|
+
// The classes are volto-authomatic's, and the styles are ported from it:
|
|
458
|
+
// two add-ons that both replace /login looking like two different
|
|
459
|
+
// products is worse than either looking like itself.
|
|
460
|
+
renderForm();
|
|
461
|
+
fireEvent.click(screen.getByText('Sign in with a password'));
|
|
462
|
+
|
|
463
|
+
expect(document.querySelector('form.PloneAuth')).toBeTruthy();
|
|
464
|
+
expect(document.querySelector('.PloneAuth .actions')).toBeTruthy();
|
|
465
|
+
expect(document.querySelector('#login-form-submit')).toBeTruthy();
|
|
466
|
+
expect(document.querySelector('#login-form-cancel')).toBeTruthy();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('clears what was typed rather than submitting it', () => {
|
|
470
|
+
const { onPasswordLogin } = renderForm();
|
|
471
|
+
fireEvent.click(screen.getByText('Sign in with a password'));
|
|
472
|
+
fireEvent.change(screen.getByLabelText('Login name'), {
|
|
473
|
+
target: { value: 'alice' },
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
|
477
|
+
|
|
478
|
+
expect(
|
|
479
|
+
(screen.getByLabelText('Login name') as HTMLInputElement).value,
|
|
480
|
+
).toBe('');
|
|
481
|
+
expect(onPasswordLogin).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
});
|