@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,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login page, without any store or routing in it.
|
|
3
|
+
*
|
|
4
|
+
* Kept presentational so it can be tested by rendering it, which is where
|
|
5
|
+
* the behaviour worth pinning lives: which buttons appear, what is disabled
|
|
6
|
+
* while a redirect is in flight, and what a failure says.
|
|
7
|
+
* @module components/Login/LoginForm
|
|
8
|
+
*/
|
|
9
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { Container } from '@plone/components';
|
|
11
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
12
|
+
|
|
13
|
+
import type { LoginProvider } from '../../types';
|
|
14
|
+
import { EMAIL_DRIVER } from '../../helpers/identities';
|
|
15
|
+
import LoginOverlay, { useDismissibleError } from './LoginOverlay';
|
|
16
|
+
import MagicLinkForm from './MagicLinkForm';
|
|
17
|
+
import PasswordForm from './PasswordForm';
|
|
18
|
+
import ProviderButton from './ProviderButton';
|
|
19
|
+
|
|
20
|
+
import './LoginForm.scss';
|
|
21
|
+
|
|
22
|
+
const messages = defineMessages({
|
|
23
|
+
loading: {
|
|
24
|
+
id: 'Loading sign-in options',
|
|
25
|
+
defaultMessage: 'Loading sign-in options…',
|
|
26
|
+
},
|
|
27
|
+
unavailable: {
|
|
28
|
+
id: 'That sign-in option is not available right now.',
|
|
29
|
+
defaultMessage: 'That sign-in option is not available right now.',
|
|
30
|
+
},
|
|
31
|
+
usePassword: {
|
|
32
|
+
id: 'Sign in with a password',
|
|
33
|
+
defaultMessage: 'Sign in with a password',
|
|
34
|
+
},
|
|
35
|
+
backToOptions: {
|
|
36
|
+
id: 'Back to sign-in options',
|
|
37
|
+
defaultMessage: 'Back to sign-in options',
|
|
38
|
+
},
|
|
39
|
+
redirecting: {
|
|
40
|
+
id: 'Taking you to {provider}…',
|
|
41
|
+
defaultMessage: 'Taking you to {provider}…',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
interface LoginFormProps {
|
|
46
|
+
providers: LoginProvider[];
|
|
47
|
+
/**
|
|
48
|
+
* Whether the sign-in options are still unknown.
|
|
49
|
+
*
|
|
50
|
+
* "Not here yet" rather than "a request is in flight": an empty
|
|
51
|
+
* `providers` on a page that has not asked yet means the same thing to
|
|
52
|
+
* every branch below as a site with none configured, and the branch for
|
|
53
|
+
* that is the local password form. The container is what can tell the two
|
|
54
|
+
* apart -- see `answered` there.
|
|
55
|
+
*/
|
|
56
|
+
loading: boolean;
|
|
57
|
+
starting: boolean;
|
|
58
|
+
error?: unknown;
|
|
59
|
+
magicLinkSent: boolean;
|
|
60
|
+
magicLinkLoading: boolean;
|
|
61
|
+
magicLinkError?: unknown;
|
|
62
|
+
/** Whether a password sign-in is in flight. */
|
|
63
|
+
passwordLoading: boolean;
|
|
64
|
+
/** Whether the last password sign-in was refused. */
|
|
65
|
+
passwordError?: unknown;
|
|
66
|
+
/**
|
|
67
|
+
* Whether Plone's own password form is one of the ways in.
|
|
68
|
+
*
|
|
69
|
+
* A prop rather than a read of `config.settings` here, so this component
|
|
70
|
+
* stays renderable without the registry and the decision is visible in
|
|
71
|
+
* one place.
|
|
72
|
+
*/
|
|
73
|
+
showPloneLogin: boolean;
|
|
74
|
+
onSelectProvider: (provider: LoginProvider) => void;
|
|
75
|
+
onSendMagicLink: (email: string) => void;
|
|
76
|
+
onPasswordLogin: (username: string, password: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The driver the local password button wears the colours of.
|
|
81
|
+
*
|
|
82
|
+
* `volto-authomatic` calls its own `plone`, and this reuses that name so the
|
|
83
|
+
* two add-ons paint the same button the same blue.
|
|
84
|
+
*/
|
|
85
|
+
const PASSWORD_DRIVER = 'plone';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which form the picker has been replaced by.
|
|
89
|
+
*
|
|
90
|
+
* `null` is the picker itself. The two named steps are the ways in that ask
|
|
91
|
+
* for something typed rather than sending the visitor to another origin.
|
|
92
|
+
*/
|
|
93
|
+
type Step = null | 'password' | 'email';
|
|
94
|
+
|
|
95
|
+
const LoginForm: React.FC<LoginFormProps> = ({
|
|
96
|
+
providers,
|
|
97
|
+
loading,
|
|
98
|
+
starting,
|
|
99
|
+
error,
|
|
100
|
+
magicLinkSent,
|
|
101
|
+
magicLinkLoading,
|
|
102
|
+
magicLinkError,
|
|
103
|
+
passwordLoading,
|
|
104
|
+
passwordError,
|
|
105
|
+
showPloneLogin,
|
|
106
|
+
onSelectProvider,
|
|
107
|
+
onSendMagicLink,
|
|
108
|
+
onPasswordLogin,
|
|
109
|
+
}) => {
|
|
110
|
+
const intl = useIntl();
|
|
111
|
+
// Which form has replaced the picker, or `null` while the picker is up.
|
|
112
|
+
//
|
|
113
|
+
// The forms replace the other ways in rather than sitting under them: each
|
|
114
|
+
// is a different way to sign in, not an extra field on this one. One state
|
|
115
|
+
// rather than a boolean each, because opening either must close the other
|
|
116
|
+
// and two booleans can both be true.
|
|
117
|
+
const [step, setStep] = useState<Step>(null);
|
|
118
|
+
// Which provider the redirect is for, so the wait can name it. The store
|
|
119
|
+
// knows a flow was started; only the click knows which button began it.
|
|
120
|
+
const [going, setGoing] = useState<LoginProvider | null>(null);
|
|
121
|
+
const [showError, dismissError] = useDismissibleError(error);
|
|
122
|
+
// Providers that sign somebody in by sending them somewhere. Email is not
|
|
123
|
+
// one: it answers with a message rather than an authorize URL, so it must
|
|
124
|
+
// stay out of every branch that redirects.
|
|
125
|
+
const buttons = providers.filter((p) => p.driver !== EMAIL_DRIVER);
|
|
126
|
+
const emailProvider = providers.find((p) => p.driver === EMAIL_DRIVER);
|
|
127
|
+
const hasMagicLink = Boolean(emailProvider);
|
|
128
|
+
// With nothing configured the password form is the login page whatever the
|
|
129
|
+
// setting says. The setting decides whether a password is offered *beside*
|
|
130
|
+
// the providers; it is not a way to leave a site with no way in, which is
|
|
131
|
+
// what a fresh install -- add-on on, no provider configured yet -- would
|
|
132
|
+
// otherwise be.
|
|
133
|
+
const hasPassword = showPloneLogin || !providers.length;
|
|
134
|
+
// Every way in, counted the same way whatever kind it is: this is what
|
|
135
|
+
// decides whether there is a choice to present at all.
|
|
136
|
+
const ways = buttons.length + (hasMagicLink ? 1 : 0) + (hasPassword ? 1 : 0);
|
|
137
|
+
const onlyProvider = ways === 1 && buttons.length === 1 ? buttons[0] : null;
|
|
138
|
+
|
|
139
|
+
// A single provider and nothing else: the picker would be one button asking
|
|
140
|
+
// the user to confirm the only thing that can happen. Sent once per mount
|
|
141
|
+
// and never while a failure is on screen -- an unreachable provider would
|
|
142
|
+
// otherwise be an unbreakable redirect loop rather than a message with a
|
|
143
|
+
// button under it.
|
|
144
|
+
const startedRef = useRef(false);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!onlyProvider || error || starting || startedRef.current) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
startedRef.current = true;
|
|
150
|
+
setGoing(onlyProvider);
|
|
151
|
+
onSelectProvider(onlyProvider);
|
|
152
|
+
}, [onlyProvider, error, starting, onSelectProvider]);
|
|
153
|
+
|
|
154
|
+
if (loading) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="identity-login">
|
|
157
|
+
<LoginOverlay message={intl.formatMessage(messages.loading)} />
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (ways === 1 && hasPassword) {
|
|
163
|
+
// Not an error: a site can legitimately have no providers configured yet,
|
|
164
|
+
// and an authorization server may never have any -- its users are local.
|
|
165
|
+
// So the password form is the login page, shown outright rather than
|
|
166
|
+
// behind a disclosure, and nothing announces the absence: the panel's
|
|
167
|
+
// description already says the account is one on this site. This is what
|
|
168
|
+
// `volto-authomatic` renders when it has no providers either.
|
|
169
|
+
return (
|
|
170
|
+
<div className="identity-login identity-login--empty">
|
|
171
|
+
<PasswordForm
|
|
172
|
+
loading={passwordLoading}
|
|
173
|
+
error={passwordError}
|
|
174
|
+
onSubmit={onPasswordLogin}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (ways === 1 && hasMagicLink) {
|
|
181
|
+
// The same argument as the password form above: one way in is not a
|
|
182
|
+
// choice, so it is not presented as one.
|
|
183
|
+
return (
|
|
184
|
+
<div className="identity-login identity-login--empty">
|
|
185
|
+
<MagicLinkForm
|
|
186
|
+
sent={magicLinkSent}
|
|
187
|
+
loading={magicLinkLoading}
|
|
188
|
+
error={magicLinkError}
|
|
189
|
+
onSend={onSendMagicLink}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (onlyProvider && !error) {
|
|
196
|
+
// The redirect is already on its way from the effect above. Saying so
|
|
197
|
+
// beats a flash of a button nobody is meant to press.
|
|
198
|
+
return (
|
|
199
|
+
<div className="identity-login">
|
|
200
|
+
<LoginOverlay
|
|
201
|
+
message={intl.formatMessage(messages.redirecting, {
|
|
202
|
+
provider: onlyProvider.title || onlyProvider.id,
|
|
203
|
+
})}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (step) {
|
|
210
|
+
// Hiding the providers without a way back would strand anyone who opened
|
|
211
|
+
// a form by mistake on the one they cannot use. The same escape for both,
|
|
212
|
+
// because both hide the same thing.
|
|
213
|
+
return (
|
|
214
|
+
<div className="identity-login">
|
|
215
|
+
{step === 'password' ? (
|
|
216
|
+
<PasswordForm
|
|
217
|
+
loading={passwordLoading}
|
|
218
|
+
error={passwordError}
|
|
219
|
+
onSubmit={onPasswordLogin}
|
|
220
|
+
/>
|
|
221
|
+
) : (
|
|
222
|
+
<MagicLinkForm
|
|
223
|
+
sent={magicLinkSent}
|
|
224
|
+
loading={magicLinkLoading}
|
|
225
|
+
error={magicLinkError}
|
|
226
|
+
onSend={onSendMagicLink}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
<Container className="identity-password-toggle">
|
|
230
|
+
<button type="button" onClick={() => setStep(null)}>
|
|
231
|
+
{intl.formatMessage(messages.backToOptions)}
|
|
232
|
+
</button>
|
|
233
|
+
</Container>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div className="identity-login">
|
|
240
|
+
{/* The password is one of the ways in, so it is one of these buttons
|
|
241
|
+
rather than a line of text under them -- when it is offered at all.
|
|
242
|
+
A site that turned it off and has only the magic link renders an
|
|
243
|
+
empty list here and the form below it. */}
|
|
244
|
+
<ul className="identity-providers">
|
|
245
|
+
{buttons.map((provider) => (
|
|
246
|
+
<li key={provider.id}>
|
|
247
|
+
<ProviderButton
|
|
248
|
+
id={provider.id}
|
|
249
|
+
driver={provider.driver}
|
|
250
|
+
label={provider.title || provider.id}
|
|
251
|
+
icon={provider.icon}
|
|
252
|
+
background_color={provider.background_color}
|
|
253
|
+
foreground_color={provider.foreground_color}
|
|
254
|
+
disabled={starting}
|
|
255
|
+
onSelect={() => {
|
|
256
|
+
setGoing(provider);
|
|
257
|
+
onSelectProvider(provider);
|
|
258
|
+
}}
|
|
259
|
+
/>
|
|
260
|
+
</li>
|
|
261
|
+
))}
|
|
262
|
+
{/* Email is a button like the rest, and opens its field on the next
|
|
263
|
+
step rather than standing open under everything else. It was the
|
|
264
|
+
only way in that did not look like one -- a labelled input on a
|
|
265
|
+
page whose question is "choose how", answered before it was
|
|
266
|
+
asked. The two that ask for something typed sit together at the
|
|
267
|
+
end, since neither leaves this origin. */}
|
|
268
|
+
{emailProvider ? (
|
|
269
|
+
<li>
|
|
270
|
+
<ProviderButton
|
|
271
|
+
id={emailProvider.id}
|
|
272
|
+
driver={emailProvider.driver}
|
|
273
|
+
label={emailProvider.title || emailProvider.id}
|
|
274
|
+
icon={emailProvider.icon}
|
|
275
|
+
background_color={emailProvider.background_color}
|
|
276
|
+
foreground_color={emailProvider.foreground_color}
|
|
277
|
+
disabled={starting}
|
|
278
|
+
onSelect={() => setStep('email')}
|
|
279
|
+
/>
|
|
280
|
+
</li>
|
|
281
|
+
) : null}
|
|
282
|
+
{hasPassword ? (
|
|
283
|
+
<li>
|
|
284
|
+
<ProviderButton
|
|
285
|
+
driver={PASSWORD_DRIVER}
|
|
286
|
+
label={intl.formatMessage(messages.usePassword)}
|
|
287
|
+
disabled={starting}
|
|
288
|
+
onSelect={() => setStep('password')}
|
|
289
|
+
/>
|
|
290
|
+
</li>
|
|
291
|
+
) : null}
|
|
292
|
+
</ul>
|
|
293
|
+
|
|
294
|
+
{/* Greying the buttons says they cannot be pressed; it does not say
|
|
295
|
+
why, or that anything is happening. The wait on top does both, and
|
|
296
|
+
names where it is going -- which is what the single-provider page
|
|
297
|
+
already did and this one did not. */}
|
|
298
|
+
{starting && !error ? (
|
|
299
|
+
<LoginOverlay
|
|
300
|
+
message={
|
|
301
|
+
going
|
|
302
|
+
? intl.formatMessage(messages.redirecting, {
|
|
303
|
+
provider: going.title || going.id,
|
|
304
|
+
})
|
|
305
|
+
: intl.formatMessage(messages.loading)
|
|
306
|
+
}
|
|
307
|
+
/>
|
|
308
|
+
) : null}
|
|
309
|
+
|
|
310
|
+
{showError ? (
|
|
311
|
+
<LoginOverlay
|
|
312
|
+
error
|
|
313
|
+
message={intl.formatMessage(messages.unavailable)}
|
|
314
|
+
onDismiss={dismissError}
|
|
315
|
+
/>
|
|
316
|
+
) : null}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export default LoginForm;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wait and the refusal, drawn over the card's body.
|
|
3
|
+
*
|
|
4
|
+
* Absolutely positioned against `LoginPanel`'s `.form`, which is what makes
|
|
5
|
+
* this an overlay rather than a third state of the page: whatever it covers
|
|
6
|
+
* keeps its size and its place, and comes back unchanged when this goes.
|
|
7
|
+
*
|
|
8
|
+
* The veil is the card's own grey, near enough opaque: what is underneath
|
|
9
|
+
* keeps its size and its place, but is not left looking usable while it is
|
|
10
|
+
* not. A paler veil read as a hole in the card, and a translucent one left
|
|
11
|
+
* buttons showing through that could not be pressed.
|
|
12
|
+
*/
|
|
13
|
+
.identity-overlay {
|
|
14
|
+
position: absolute;
|
|
15
|
+
z-index: 1;
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
/* Twice the gap at the sides: the card is 350px wide and a refusal is a
|
|
20
|
+
sentence, so with only the rhythm's own padding it ran to both edges and
|
|
21
|
+
read as text pasted over the card rather than as a message on it. */
|
|
22
|
+
padding: var(--identity-gap) calc(var(--identity-gap) * 2);
|
|
23
|
+
background: var(--identity-overlay-veil);
|
|
24
|
+
inset: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.identity-overlay__body {
|
|
28
|
+
display: flex;
|
|
29
|
+
max-width: 100%;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: 10px;
|
|
33
|
+
text-align: center;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.identity-overlay__message {
|
|
37
|
+
margin: 0;
|
|
38
|
+
color: var(--identity-muted);
|
|
39
|
+
font-size: 14px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.identity-overlay--error .identity-overlay__message {
|
|
43
|
+
color: var(--identity-error);
|
|
44
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import LoginOverlay from './LoginOverlay';
|
|
5
|
+
import { withLoginCard } from '../../stories/fixtures';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Something for the overlay to be over.
|
|
9
|
+
*
|
|
10
|
+
* The point of an overlay is what stays underneath it, so a story that showed
|
|
11
|
+
* it against an empty card would not show the thing worth looking at.
|
|
12
|
+
*/
|
|
13
|
+
const Underneath = () => (
|
|
14
|
+
<ul className="identity-providers">
|
|
15
|
+
<li>
|
|
16
|
+
<button type="button" className="identity-provider" disabled>
|
|
17
|
+
<span>GitHub</span>
|
|
18
|
+
</button>
|
|
19
|
+
</li>
|
|
20
|
+
<li>
|
|
21
|
+
<button type="button" className="identity-provider" disabled>
|
|
22
|
+
<span>Sign in with a password</span>
|
|
23
|
+
</button>
|
|
24
|
+
</li>
|
|
25
|
+
</ul>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const meta: Meta<typeof LoginOverlay> = {
|
|
29
|
+
title: 'Identity/Login/LoginOverlay',
|
|
30
|
+
component: LoginOverlay,
|
|
31
|
+
decorators: [
|
|
32
|
+
(Story) => (
|
|
33
|
+
<div className="identity-login">
|
|
34
|
+
<Underneath />
|
|
35
|
+
{Story()}
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
withLoginCard(),
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
export default meta;
|
|
42
|
+
|
|
43
|
+
type Story = StoryObj<typeof LoginOverlay>;
|
|
44
|
+
|
|
45
|
+
/** Waiting on the provider listing, before there is anything to choose. */
|
|
46
|
+
export const Loading: Story = { args: { message: 'Loading sign-in options…' } };
|
|
47
|
+
|
|
48
|
+
/** Waiting on a redirect, with the options it came from still underneath. */
|
|
49
|
+
export const Redirecting: Story = {
|
|
50
|
+
args: { message: 'Taking you to GitHub…' },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** A refusal, which the reader ends by reading it. */
|
|
54
|
+
export const Refused: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
error: true,
|
|
57
|
+
message: 'That sign-in option is not available right now.',
|
|
58
|
+
onDismiss: () => {},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A refusal with nothing to go back to.
|
|
64
|
+
*
|
|
65
|
+
* No dismiss control, because taking it down would leave a card saying
|
|
66
|
+
* nothing at all.
|
|
67
|
+
*/
|
|
68
|
+
export const RefusedWithNoWayBack: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
error: true,
|
|
71
|
+
message: 'That sign-in option is not available right now.',
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '../../testing';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import LoginOverlay, { useDismissibleError } from './LoginOverlay';
|
|
6
|
+
|
|
7
|
+
describe('LoginOverlay', () => {
|
|
8
|
+
it('announces a wait without interrupting', () => {
|
|
9
|
+
// A wait is a status: something is happening and the reader may keep
|
|
10
|
+
// reading. An alert would interrupt them to say so.
|
|
11
|
+
render(<LoginOverlay message="Taking you to Dex…" />);
|
|
12
|
+
|
|
13
|
+
expect(screen.getByRole('status').textContent).toContain('Dex');
|
|
14
|
+
expect(document.querySelector('.identity-spinner')).toBeTruthy();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('announces a refusal as one', () => {
|
|
18
|
+
render(<LoginOverlay error message="That did not work." />);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByRole('alert').textContent).toContain('did not work');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('draws no spinner on a refusal', () => {
|
|
24
|
+
// Nothing is happening, which is the whole message.
|
|
25
|
+
render(<LoginOverlay error message="That did not work." />);
|
|
26
|
+
|
|
27
|
+
expect(document.querySelector('.identity-spinner')).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('offers no way out of a wait', () => {
|
|
31
|
+
// It is not the reader's to end, and a button would say otherwise.
|
|
32
|
+
render(<LoginOverlay message="Signing in" onDismiss={vi.fn()} />);
|
|
33
|
+
|
|
34
|
+
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('offers a way out of a refusal', () => {
|
|
38
|
+
const onDismiss = vi.fn();
|
|
39
|
+
render(
|
|
40
|
+
<LoginOverlay error message="That did not work." onDismiss={onDismiss} />,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
44
|
+
|
|
45
|
+
expect(onDismiss).toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('has no dismiss button when nothing can be dismissed to', () => {
|
|
49
|
+
render(<LoginOverlay error message="That did not work." />);
|
|
50
|
+
|
|
51
|
+
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** A host for the hook, since a hook cannot be rendered on its own. */
|
|
56
|
+
const Host: React.FC<{ error: unknown }> = ({ error }) => {
|
|
57
|
+
const [visible, dismiss] = useDismissibleError(error);
|
|
58
|
+
return visible ? (
|
|
59
|
+
<button type="button" onClick={dismiss}>
|
|
60
|
+
showing
|
|
61
|
+
</button>
|
|
62
|
+
) : (
|
|
63
|
+
<span>hidden</span>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe('useDismissibleError', () => {
|
|
68
|
+
it('shows a refusal that has not been dismissed', () => {
|
|
69
|
+
render(<Host error={{ status: 401 }} />);
|
|
70
|
+
|
|
71
|
+
expect(screen.getByText('showing')).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('shows nothing when there is no refusal', () => {
|
|
75
|
+
render(<Host error={null} />);
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText('hidden')).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('hides the one that was dismissed', () => {
|
|
81
|
+
render(<Host error={{ status: 401 }} />);
|
|
82
|
+
|
|
83
|
+
fireEvent.click(screen.getByText('showing'));
|
|
84
|
+
|
|
85
|
+
expect(screen.getByText('hidden')).toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('shows the next one anyway', () => {
|
|
89
|
+
// Dismissing is about the refusal that was read, not about refusals in
|
|
90
|
+
// general -- a second wrong password has to say so again.
|
|
91
|
+
const { rerender } = render(<Host error={{ status: 401 }} />);
|
|
92
|
+
fireEvent.click(screen.getByText('showing'));
|
|
93
|
+
|
|
94
|
+
rerender(<Host error={{ status: 401 }} />);
|
|
95
|
+
|
|
96
|
+
expect(screen.getByText('showing')).toBeTruthy();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the login card says over itself: a wait, or a refusal.
|
|
3
|
+
*
|
|
4
|
+
* An overlay rather than a branch, because both used to *replace* what they
|
|
5
|
+
* were about. A wait swapped the options for a line of text, so the card
|
|
6
|
+
* changed size and the reader lost their place; a refusal appeared as a
|
|
7
|
+
* paragraph that pushed everything below it down. Neither is a different
|
|
8
|
+
* page -- they are something happening to the page that is already there, and
|
|
9
|
+
* that is what an overlay is for.
|
|
10
|
+
*
|
|
11
|
+
* Two kinds, and the difference is who ends them. A wait ends when whatever
|
|
12
|
+
* is being waited for answers, so it has no control on it. A refusal ends
|
|
13
|
+
* when the reader has read it, so it does: leaving it up would hide the form
|
|
14
|
+
* they need to try again in.
|
|
15
|
+
*
|
|
16
|
+
* Positioned against the nearest positioned ancestor, which is
|
|
17
|
+
* `LoginPanel`'s `.form` -- the card's body, and the thing whose size should
|
|
18
|
+
* not change while this is up.
|
|
19
|
+
* @module components/Login/LoginOverlay
|
|
20
|
+
*/
|
|
21
|
+
import React, { useState } from 'react';
|
|
22
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
23
|
+
|
|
24
|
+
import './LoginOverlay.scss';
|
|
25
|
+
|
|
26
|
+
const messages = defineMessages({
|
|
27
|
+
dismiss: { id: 'Close', defaultMessage: 'Close' },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
interface LoginOverlayProps {
|
|
31
|
+
/** What is being waited for, or what was refused. */
|
|
32
|
+
message: string;
|
|
33
|
+
/**
|
|
34
|
+
* Whether this is a refusal rather than a wait.
|
|
35
|
+
*
|
|
36
|
+
* A refusal is announced as an alert and carries no spinner: nothing is
|
|
37
|
+
* happening, which is the whole message.
|
|
38
|
+
*/
|
|
39
|
+
error?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Take this overlay down.
|
|
42
|
+
*
|
|
43
|
+
* Only meaningful on a refusal -- a wait is not the reader's to end, and a
|
|
44
|
+
* dismiss button on one would suggest it is. Absent even on a refusal where
|
|
45
|
+
* there is nothing underneath to go back to.
|
|
46
|
+
*/
|
|
47
|
+
onDismiss?: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const LoginOverlay: React.FC<LoginOverlayProps> = ({
|
|
51
|
+
message,
|
|
52
|
+
error = false,
|
|
53
|
+
onDismiss,
|
|
54
|
+
}) => {
|
|
55
|
+
const intl = useIntl();
|
|
56
|
+
const kind = error ? 'identity-overlay--error' : 'identity-overlay--waiting';
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className={`identity-overlay ${kind}`}
|
|
61
|
+
role={error ? 'alert' : 'status'}
|
|
62
|
+
>
|
|
63
|
+
<div className="identity-overlay__body">
|
|
64
|
+
{error ? null : (
|
|
65
|
+
<span className="identity-spinner" aria-hidden="true" />
|
|
66
|
+
)}
|
|
67
|
+
<p className="identity-overlay__message">{message}</p>
|
|
68
|
+
{error && onDismiss ? (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
// Filled: it is the only thing on the overlay that can be
|
|
72
|
+
// pressed, and an outlined button on a veil read as disabled.
|
|
73
|
+
className="identity-button identity-button--primary"
|
|
74
|
+
data-action="dismiss"
|
|
75
|
+
onClick={onDismiss}
|
|
76
|
+
>
|
|
77
|
+
{intl.formatMessage(messages.dismiss)}
|
|
78
|
+
</button>
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a refusal is still worth showing, and how to stop showing it.
|
|
87
|
+
*
|
|
88
|
+
* The refusal itself lives in the store, so "dismissed" cannot: clearing it
|
|
89
|
+
* there would be this component deciding a request never failed. What is
|
|
90
|
+
* remembered instead is *which* refusal was dismissed, so the next one --
|
|
91
|
+
* a second wrong password, a second unreachable provider -- says so again
|
|
92
|
+
* rather than being silently swallowed by the first one's dismissal.
|
|
93
|
+
*
|
|
94
|
+
* The identity of the error value is what distinguishes them, which is what
|
|
95
|
+
* the request reducers produce: a fresh object per failure. A backend that
|
|
96
|
+
* somehow returned the very same object twice would have its second refusal
|
|
97
|
+
* hidden, which is a better failure than a refusal that cannot be dismissed.
|
|
98
|
+
*
|
|
99
|
+
* @param error The refusal, or a falsy value when there is none.
|
|
100
|
+
* @returns Whether to show it, and a function that takes it down.
|
|
101
|
+
*/
|
|
102
|
+
export function useDismissibleError(error: unknown): [boolean, () => void] {
|
|
103
|
+
const [dismissed, setDismissed] = useState<unknown>(undefined);
|
|
104
|
+
return [Boolean(error) && dismissed !== error, () => setDismissed(error)];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default LoginOverlay;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login card.
|
|
3
|
+
*
|
|
4
|
+
* The card itself is `.identity-surface` and its heading
|
|
5
|
+
* `.identity-surface__header`, both from the root stylesheet: this is the same
|
|
6
|
+
* card the control panel's sections are. Only what is particular to the login
|
|
7
|
+
* page lives here -- its width, and the description strip under the heading.
|
|
8
|
+
*/
|
|
9
|
+
#page-login {
|
|
10
|
+
/* `Container` only centres itself when it is given one of its own width
|
|
11
|
+
modifiers -- `narrow`, `default`, `layout` -- and this one is sized by us
|
|
12
|
+
instead, so the auto margins have to come from here. Without them the card
|
|
13
|
+
sits against the left edge of the window. */
|
|
14
|
+
.loginForm {
|
|
15
|
+
width: var(--identity-login-width);
|
|
16
|
+
max-width: 100%;
|
|
17
|
+
line-height: 1.5;
|
|
18
|
+
margin-inline: auto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.wrapper {
|
|
22
|
+
margin: 1rem 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* The card's body. Two things it must do beyond holding what is in it:
|
|
26
|
+
never get shorter than the floor -- every state of the page lives here,
|
|
27
|
+
and a card that resized on each of them read as the page jumping about
|
|
28
|
+
-- and be the positioned ancestor a wait or a refusal is drawn against,
|
|
29
|
+
so an overlay covers this and nothing else. */
|
|
30
|
+
.form {
|
|
31
|
+
position: relative;
|
|
32
|
+
display: flex;
|
|
33
|
+
min-height: var(--identity-login-min-height);
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
/* Centred rather than stacked from the top. The floor above means a short
|
|
36
|
+
state -- the first-login decision, the callback's one line -- otherwise
|
|
37
|
+
sits against the top of a box more than twice its height, which reads as
|
|
38
|
+
a card that failed to fill rather than one with little to say. Anything
|
|
39
|
+
taller than the floor makes the box grow, so nothing is ever centred out
|
|
40
|
+
of reach. */
|
|
41
|
+
justify-content: center;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.description {
|
|
45
|
+
padding: 12px;
|
|
46
|
+
background-color: var(--identity-surface-header-bg);
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
font-weight: 200;
|
|
49
|
+
}
|
|
50
|
+
}
|