@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,40 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import LoginPanel from './LoginPanel';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof LoginPanel> = {
|
|
7
|
+
title: 'Identity/Login/LoginPanel',
|
|
8
|
+
component: LoginPanel,
|
|
9
|
+
args: {
|
|
10
|
+
title: 'Log in',
|
|
11
|
+
description: 'Choose how you would like to sign in.',
|
|
12
|
+
children: (
|
|
13
|
+
// Centred and padded, so the story shows the card's body as the page
|
|
14
|
+
// fills it rather than a line of text against its top-left corner.
|
|
15
|
+
<p
|
|
16
|
+
style={{
|
|
17
|
+
display: 'flex',
|
|
18
|
+
height: '100%',
|
|
19
|
+
alignItems: 'center',
|
|
20
|
+
justifyContent: 'center',
|
|
21
|
+
padding: 'var(--identity-gap)',
|
|
22
|
+
margin: 0,
|
|
23
|
+
textAlign: 'center',
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
The sign-in options go here.
|
|
27
|
+
</p>
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export default meta;
|
|
32
|
+
|
|
33
|
+
type Story = StoryObj<typeof LoginPanel>;
|
|
34
|
+
|
|
35
|
+
/** The chrome, which is volto-authomatic's so the two add-ons match. */
|
|
36
|
+
export const Default: Story = {};
|
|
37
|
+
|
|
38
|
+
export const NoProvidersConfigured: Story = {
|
|
39
|
+
args: { description: 'Sign in with your account on this site.' },
|
|
40
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chrome around the login page.
|
|
3
|
+
*
|
|
4
|
+
* The panel, its markup and its classes are taken from `volto-authomatic`,
|
|
5
|
+
* which is the shape Volto's own login page has: a fixed-width card with a
|
|
6
|
+
* titled header, a description strip, and the form below. Two add-ons that
|
|
7
|
+
* both replace `/login` looking like two different products is a worse
|
|
8
|
+
* outcome than either of them looking like itself.
|
|
9
|
+
*
|
|
10
|
+
* The card and its heading additionally wear the add-on's own `identity-surface`
|
|
11
|
+
* classes, so this panel and the control panel's sections are the same card
|
|
12
|
+
* described once.
|
|
13
|
+
*
|
|
14
|
+
* Every string it renders is a prop: the container decides what the page is
|
|
15
|
+
* called and what it says, because that depends on what is actually below.
|
|
16
|
+
* @module components/Login/LoginPanel
|
|
17
|
+
*/
|
|
18
|
+
import React from 'react';
|
|
19
|
+
import type { ReactNode } from 'react';
|
|
20
|
+
import { Container } from '@plone/components';
|
|
21
|
+
import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
|
|
22
|
+
|
|
23
|
+
import './LoginPanel.scss';
|
|
24
|
+
|
|
25
|
+
interface LoginPanelProps {
|
|
26
|
+
/** Heading, and the browser title. */
|
|
27
|
+
title: string;
|
|
28
|
+
/**
|
|
29
|
+
* The strip under the heading, saying what the options below are.
|
|
30
|
+
*
|
|
31
|
+
* Optional: the callback and the first-login wait use this same card, and
|
|
32
|
+
* neither has anything to say there. An empty strip is still a coloured
|
|
33
|
+
* band with padding, so it is left out rather than rendered blank.
|
|
34
|
+
*/
|
|
35
|
+
description?: string;
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LoginPanel: React.FC<LoginPanelProps> = ({
|
|
40
|
+
title,
|
|
41
|
+
description,
|
|
42
|
+
children,
|
|
43
|
+
}) => (
|
|
44
|
+
<div id="page-login">
|
|
45
|
+
<Helmet title={title} />
|
|
46
|
+
<Container className="loginForm">
|
|
47
|
+
<Container className="wrapper identity-surface">
|
|
48
|
+
<Container className="title identity-surface__header">
|
|
49
|
+
{title}
|
|
50
|
+
</Container>
|
|
51
|
+
{description ? (
|
|
52
|
+
<Container className="description">{description}</Container>
|
|
53
|
+
) : null}
|
|
54
|
+
<Container className="form">{children}</Container>
|
|
55
|
+
</Container>
|
|
56
|
+
</Container>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
export default LoginPanel;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "email me a link" form, and what it says once the link is on its way.
|
|
3
|
+
*
|
|
4
|
+
* The form itself wears `.PloneAuth`, so its field, its underline and its
|
|
5
|
+
* action row are styled by `PasswordForm.scss` -- the two forms on this card
|
|
6
|
+
* are the same form and are styled once. Only what is particular to this one
|
|
7
|
+
* lives here: a single field needs no divider above its action row, and the
|
|
8
|
+
* confirmation that replaces the whole form is not a form at all.
|
|
9
|
+
*/
|
|
10
|
+
.identity-magic-link {
|
|
11
|
+
&.PloneAuth .actions {
|
|
12
|
+
padding-top: 0;
|
|
13
|
+
/* `PasswordForm` rules a line above its two buttons to separate them from
|
|
14
|
+
two fields and a password-reset link. Above a single field it is a rule
|
|
15
|
+
under nothing. */
|
|
16
|
+
border-top: 0;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.identity-magic-link--sent {
|
|
21
|
+
padding: var(--identity-gap) 0;
|
|
22
|
+
margin: 0;
|
|
23
|
+
color: var(--identity-muted);
|
|
24
|
+
text-align: center;
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import MagicLinkForm from './MagicLinkForm';
|
|
4
|
+
import { FAILED, withLoginCard } from '../../stories/fixtures';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof MagicLinkForm> = {
|
|
7
|
+
title: 'Identity/Login/MagicLinkForm',
|
|
8
|
+
component: MagicLinkForm,
|
|
9
|
+
decorators: [withLoginCard()],
|
|
10
|
+
args: { sent: false, loading: false, onSend: () => {} },
|
|
11
|
+
};
|
|
12
|
+
export default meta;
|
|
13
|
+
|
|
14
|
+
type Story = StoryObj<typeof MagicLinkForm>;
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {};
|
|
17
|
+
|
|
18
|
+
export const Sending: Story = { args: { loading: true } };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The confirmation says nothing about whether the address is known: the
|
|
22
|
+
* backend answers identically either way, and a UI that distinguished them
|
|
23
|
+
* would undo that.
|
|
24
|
+
*/
|
|
25
|
+
export const Sent: Story = { args: { sent: true } };
|
|
26
|
+
|
|
27
|
+
export const Failed: Story = { args: { error: FAILED.error } };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "email me a link" way in.
|
|
3
|
+
*
|
|
4
|
+
* The markup and the classes are `PasswordForm`'s, which are
|
|
5
|
+
* `volto-authomatic`'s `PloneForm`, which is the shape Volto's own login form
|
|
6
|
+
* has: a labelled field with a single underline, and the submit as an icon
|
|
7
|
+
* button on a divided row. The two forms on this page ask for one and two
|
|
8
|
+
* fields respectively and are otherwise the same form -- which is the point.
|
|
9
|
+
* Before this they were not: a bare `<label>` and `<input>` next to a fully
|
|
10
|
+
* dressed `PloneAuth`, on the same card, one click apart.
|
|
11
|
+
*
|
|
12
|
+
* Just the form. Whether it is shown at all, and what it replaces when it is,
|
|
13
|
+
* belongs to `LoginForm`.
|
|
14
|
+
* @module components/Login/MagicLinkForm
|
|
15
|
+
*/
|
|
16
|
+
import React, { useState } from 'react';
|
|
17
|
+
import type { FormEvent } from 'react';
|
|
18
|
+
import { Button, Container, TextField } from '@plone/components';
|
|
19
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
20
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
21
|
+
import aheadSVG from '@plone/volto/icons/ahead.svg';
|
|
22
|
+
|
|
23
|
+
// The widget's own stylesheet, for the same reason `PasswordForm` imports it:
|
|
24
|
+
// `@plone/components` ships its CSS separately from its components, so a
|
|
25
|
+
// TextField rendered without this is unstyled.
|
|
26
|
+
import '@plone/components/src/styles/basic/TextField.css';
|
|
27
|
+
|
|
28
|
+
import LoginOverlay, { useDismissibleError } from './LoginOverlay';
|
|
29
|
+
|
|
30
|
+
import './MagicLinkForm.scss';
|
|
31
|
+
|
|
32
|
+
const messages = defineMessages({
|
|
33
|
+
email: { id: 'Email address', defaultMessage: 'Email address' },
|
|
34
|
+
send: { id: 'Email me a link', defaultMessage: 'Email me a link' },
|
|
35
|
+
sending: { id: 'Sending', defaultMessage: 'Sending' },
|
|
36
|
+
sent: {
|
|
37
|
+
id: 'If that address can sign in here',
|
|
38
|
+
defaultMessage:
|
|
39
|
+
'If that address can sign in here, a link is on its way. It works ' +
|
|
40
|
+
'once, and only for a few minutes.',
|
|
41
|
+
},
|
|
42
|
+
failed: {
|
|
43
|
+
id: 'That did not work. Please try again in a little while.',
|
|
44
|
+
defaultMessage: 'That did not work. Please try again in a little while.',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
interface MagicLinkFormProps {
|
|
49
|
+
sent: boolean;
|
|
50
|
+
loading: boolean;
|
|
51
|
+
error?: unknown;
|
|
52
|
+
onSend: (email: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MagicLinkForm: React.FC<MagicLinkFormProps> = ({
|
|
56
|
+
sent,
|
|
57
|
+
loading,
|
|
58
|
+
error,
|
|
59
|
+
onSend,
|
|
60
|
+
}) => {
|
|
61
|
+
const intl = useIntl();
|
|
62
|
+
const [email, setEmail] = useState('');
|
|
63
|
+
const [showError, dismissError] = useDismissibleError(error);
|
|
64
|
+
|
|
65
|
+
if (sent) {
|
|
66
|
+
// Deliberately says nothing about whether the address is known: the
|
|
67
|
+
// backend answers identically either way, and a UI that distinguished
|
|
68
|
+
// them would undo that.
|
|
69
|
+
return (
|
|
70
|
+
<p
|
|
71
|
+
className="identity-magic-link identity-magic-link--sent"
|
|
72
|
+
role="status"
|
|
73
|
+
>
|
|
74
|
+
{intl.formatMessage(messages.sent)}
|
|
75
|
+
</p>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
if (email.trim()) {
|
|
82
|
+
onSend(email.trim());
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<form
|
|
88
|
+
method="post"
|
|
89
|
+
className="PloneAuth identity-magic-link"
|
|
90
|
+
onSubmit={submit}
|
|
91
|
+
>
|
|
92
|
+
<Container className="form">
|
|
93
|
+
<TextField
|
|
94
|
+
label={intl.formatMessage(messages.email)}
|
|
95
|
+
name="email"
|
|
96
|
+
type="email"
|
|
97
|
+
placeholder={intl.formatMessage(messages.email)}
|
|
98
|
+
autoComplete="email"
|
|
99
|
+
isRequired
|
|
100
|
+
value={email}
|
|
101
|
+
onChange={setEmail}
|
|
102
|
+
/>
|
|
103
|
+
</Container>
|
|
104
|
+
|
|
105
|
+
<Container className="actions">
|
|
106
|
+
<Button
|
|
107
|
+
id="magic-link-form-submit"
|
|
108
|
+
type="submit"
|
|
109
|
+
isDisabled={loading || !email.trim()}
|
|
110
|
+
aria-label={intl.formatMessage(
|
|
111
|
+
loading ? messages.sending : messages.send,
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
<Icon className="circled" name={aheadSVG} size="30px" />
|
|
115
|
+
</Button>
|
|
116
|
+
</Container>
|
|
117
|
+
|
|
118
|
+
{loading ? (
|
|
119
|
+
<LoginOverlay message={intl.formatMessage(messages.sending)} />
|
|
120
|
+
) : null}
|
|
121
|
+
|
|
122
|
+
{showError ? (
|
|
123
|
+
<LoginOverlay
|
|
124
|
+
error
|
|
125
|
+
message={intl.formatMessage(messages.failed)}
|
|
126
|
+
onDismiss={dismissError}
|
|
127
|
+
/>
|
|
128
|
+
) : null}
|
|
129
|
+
</form>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export default MagicLinkForm;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The password form, following volto-authomatic's `PloneForm`.
|
|
3
|
+
*
|
|
4
|
+
* That is the shape Volto's own login form has: labelled fields with a single
|
|
5
|
+
* underline, and a submit and a cancel as icon buttons on a divided row. The
|
|
6
|
+
* class names are its class names, so the two forms are the same form.
|
|
7
|
+
*
|
|
8
|
+
* The control that opens it lives in `LoginForm.scss`, with the markup.
|
|
9
|
+
*/
|
|
10
|
+
/* The width is explicit because the auto margins below cost the stretch that
|
|
11
|
+
would otherwise have supplied one: this form is a flex item of
|
|
12
|
+
`.identity-login`, and a flex item with an auto margin on the cross axis is
|
|
13
|
+
sized to its content rather than stretched to the line. Left at
|
|
14
|
+
`max-width: 100%` it collapsed to whatever the fields happened to need. */
|
|
15
|
+
.PloneAuth {
|
|
16
|
+
display: flex;
|
|
17
|
+
width: 80%;
|
|
18
|
+
max-width: 100%;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
margin-right: auto;
|
|
21
|
+
margin-left: auto;
|
|
22
|
+
line-height: 1.5;
|
|
23
|
+
|
|
24
|
+
.form {
|
|
25
|
+
padding: 10px;
|
|
26
|
+
|
|
27
|
+
.react-aria-TextField {
|
|
28
|
+
width: 100%;
|
|
29
|
+
padding: 10px 0;
|
|
30
|
+
|
|
31
|
+
.react-aria-Label {
|
|
32
|
+
font-size: 14px;
|
|
33
|
+
font-weight: 500;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* The whole border rather than just its width: `TextField.css` styles
|
|
37
|
+
one with a custom property this add-on does not load, so what is
|
|
38
|
+
inherited from it is no border at all. */
|
|
39
|
+
.react-aria-Input {
|
|
40
|
+
width: 100%;
|
|
41
|
+
padding: 10px 5px;
|
|
42
|
+
border: 0;
|
|
43
|
+
border-bottom: 1px solid var(--identity-field-border);
|
|
44
|
+
margin-top: 10px;
|
|
45
|
+
background: transparent;
|
|
46
|
+
font-size: 18px;
|
|
47
|
+
|
|
48
|
+
&:not(:focus) {
|
|
49
|
+
border-radius: 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.forgotPassword {
|
|
56
|
+
padding: 10px 10px 20px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.actions {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-direction: row-reverse;
|
|
62
|
+
padding: 20px 10px;
|
|
63
|
+
border-top: 1px solid var(--identity-surface-header-bg);
|
|
64
|
+
|
|
65
|
+
> button {
|
|
66
|
+
border: unset;
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
|
|
69
|
+
&.react-aria-Button {
|
|
70
|
+
background-color: transparent !important;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
&#login-form-submit {
|
|
74
|
+
color: var(--identity-accent);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
&#login-form-cancel {
|
|
78
|
+
color: var(--identity-danger);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import PasswordForm from './PasswordForm';
|
|
4
|
+
import { FAILED, withLoginCard } from '../../stories/fixtures';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof PasswordForm> = {
|
|
7
|
+
title: 'Identity/Login/PasswordForm',
|
|
8
|
+
component: PasswordForm,
|
|
9
|
+
// The sentence the real page carries above this form when it is the
|
|
10
|
+
// only way in, which is the case it is worth comparing against.
|
|
11
|
+
decorators: [withLoginCard('Sign in with your account on this site.')],
|
|
12
|
+
args: { loading: false, onSubmit: () => {} },
|
|
13
|
+
};
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj<typeof PasswordForm>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The form on its own. Whether the login page shows it, and what it replaces
|
|
20
|
+
* when it does, is `LoginForm`'s decision -- see its own stories.
|
|
21
|
+
*/
|
|
22
|
+
export const Default: Story = {};
|
|
23
|
+
|
|
24
|
+
export const Refused: Story = { args: { error: FAILED.error } };
|
|
25
|
+
|
|
26
|
+
export const Authenticating: Story = { args: { loading: true } };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in with a local account.
|
|
3
|
+
*
|
|
4
|
+
* This route replaces Volto's own login page, so without it a site that
|
|
5
|
+
* installs this add-on loses password sign-in entirely. That is wrong for any
|
|
6
|
+
* site with local accounts and impossible for one running the `[server]`
|
|
7
|
+
* layer: an authorization server has to be able to authenticate its own
|
|
8
|
+
* users, and "sign in with somebody else" is not an answer it can give.
|
|
9
|
+
*
|
|
10
|
+
* The markup and the classes follow `volto-authomatic`'s `PloneForm`, which
|
|
11
|
+
* is the shape Volto's own login form has: labelled fields with a single
|
|
12
|
+
* underline, and a submit and a cancel as icon buttons on a divided row.
|
|
13
|
+
*
|
|
14
|
+
* Just the form. Whether it is shown at all, and what it replaces when it is,
|
|
15
|
+
* belongs to `LoginForm`: opening the password form hides the providers, and a
|
|
16
|
+
* component cannot hide its own siblings.
|
|
17
|
+
* @module components/Login/PasswordForm
|
|
18
|
+
*/
|
|
19
|
+
import React, { useState } from 'react';
|
|
20
|
+
import type { FormEvent } from 'react';
|
|
21
|
+
import { Button, Container, TextField } from '@plone/components';
|
|
22
|
+
import { Link } from 'react-router-dom';
|
|
23
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
24
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
25
|
+
import aheadSVG from '@plone/volto/icons/ahead.svg';
|
|
26
|
+
import clearSVG from '@plone/volto/icons/clear.svg';
|
|
27
|
+
|
|
28
|
+
// The widget's own stylesheet. `@plone/components` ships its CSS separately
|
|
29
|
+
// from its components, so a TextField rendered without this is unstyled --
|
|
30
|
+
// which is what made this form look unlike volto-authomatic's despite the
|
|
31
|
+
// markup and the class names matching it exactly.
|
|
32
|
+
import '@plone/components/src/styles/basic/TextField.css';
|
|
33
|
+
|
|
34
|
+
import LoginOverlay, { useDismissibleError } from './LoginOverlay';
|
|
35
|
+
|
|
36
|
+
import './PasswordForm.scss';
|
|
37
|
+
|
|
38
|
+
const messages = defineMessages({
|
|
39
|
+
loginName: { id: 'Login name', defaultMessage: 'Login name' },
|
|
40
|
+
password: { id: 'Password', defaultMessage: 'Password' },
|
|
41
|
+
forgotPassword: {
|
|
42
|
+
id: 'box_forgot_password_option',
|
|
43
|
+
defaultMessage: 'Forgot your password?',
|
|
44
|
+
},
|
|
45
|
+
refused: {
|
|
46
|
+
id: 'That login name and password did not match.',
|
|
47
|
+
defaultMessage: 'That login name and password did not match.',
|
|
48
|
+
},
|
|
49
|
+
signIn: { id: 'Sign in', defaultMessage: 'Sign in' },
|
|
50
|
+
signingIn: { id: 'Signing in', defaultMessage: 'Signing in' },
|
|
51
|
+
clear: { id: 'Clear', defaultMessage: 'Clear' },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
interface PasswordFormProps {
|
|
55
|
+
/** Whether a sign-in attempt is in flight. */
|
|
56
|
+
loading: boolean;
|
|
57
|
+
/** Whether the last attempt was refused. */
|
|
58
|
+
error?: unknown;
|
|
59
|
+
/** Called with the credentials to try. */
|
|
60
|
+
onSubmit: (username: string, password: string) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PasswordForm: React.FC<PasswordFormProps> = ({
|
|
64
|
+
loading,
|
|
65
|
+
error,
|
|
66
|
+
onSubmit,
|
|
67
|
+
}) => {
|
|
68
|
+
const intl = useIntl();
|
|
69
|
+
const [username, setUsername] = useState('');
|
|
70
|
+
const [password, setPassword] = useState('');
|
|
71
|
+
const [showError, dismissError] = useDismissibleError(error);
|
|
72
|
+
|
|
73
|
+
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
if (username && password) {
|
|
76
|
+
onSubmit(username, password);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const clear = () => {
|
|
81
|
+
setUsername('');
|
|
82
|
+
setPassword('');
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<form method="post" className="PloneAuth" onSubmit={submit}>
|
|
87
|
+
<Container className="form">
|
|
88
|
+
<TextField
|
|
89
|
+
label={intl.formatMessage(messages.loginName)}
|
|
90
|
+
name="username"
|
|
91
|
+
placeholder={intl.formatMessage(messages.loginName)}
|
|
92
|
+
autoComplete="username"
|
|
93
|
+
isRequired
|
|
94
|
+
value={username}
|
|
95
|
+
onChange={setUsername}
|
|
96
|
+
/>
|
|
97
|
+
<TextField
|
|
98
|
+
label={intl.formatMessage(messages.password)}
|
|
99
|
+
name="password"
|
|
100
|
+
type="password"
|
|
101
|
+
placeholder={intl.formatMessage(messages.password)}
|
|
102
|
+
autoComplete="current-password"
|
|
103
|
+
isRequired
|
|
104
|
+
value={password}
|
|
105
|
+
onChange={setPassword}
|
|
106
|
+
/>
|
|
107
|
+
</Container>
|
|
108
|
+
|
|
109
|
+
<Container className="forgotPassword">
|
|
110
|
+
<p className="help">
|
|
111
|
+
<Link to="/passwordreset">
|
|
112
|
+
{intl.formatMessage(messages.forgotPassword)}
|
|
113
|
+
</Link>
|
|
114
|
+
</p>
|
|
115
|
+
</Container>
|
|
116
|
+
|
|
117
|
+
<Container className="actions">
|
|
118
|
+
<Button
|
|
119
|
+
id="login-form-submit"
|
|
120
|
+
type="submit"
|
|
121
|
+
isDisabled={loading || !username || !password}
|
|
122
|
+
aria-label={intl.formatMessage(
|
|
123
|
+
loading ? messages.signingIn : messages.signIn,
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
<Icon className="circled" name={aheadSVG} size="30px" />
|
|
127
|
+
</Button>
|
|
128
|
+
|
|
129
|
+
<Button
|
|
130
|
+
id="login-form-cancel"
|
|
131
|
+
type="button"
|
|
132
|
+
onPress={clear}
|
|
133
|
+
aria-label={intl.formatMessage(messages.clear)}
|
|
134
|
+
>
|
|
135
|
+
<Icon className="circled" name={clearSVG} size="30px" />
|
|
136
|
+
</Button>
|
|
137
|
+
</Container>
|
|
138
|
+
|
|
139
|
+
{/* Over the form rather than inside it: a refusal that pushed the
|
|
140
|
+
action row down moved the button the reader was about to press
|
|
141
|
+
again. The wait carries no dismiss -- it is not theirs to end. */}
|
|
142
|
+
{loading ? (
|
|
143
|
+
<LoginOverlay message={intl.formatMessage(messages.signingIn)} />
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
{/* One message for a wrong name and a wrong password alike: telling
|
|
147
|
+
them apart is an account-enumeration oracle. */}
|
|
148
|
+
{showError ? (
|
|
149
|
+
<LoginOverlay
|
|
150
|
+
error
|
|
151
|
+
message={intl.formatMessage(messages.refused)}
|
|
152
|
+
onDismiss={dismissError}
|
|
153
|
+
/>
|
|
154
|
+
) : null}
|
|
155
|
+
</form>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export default PasswordForm;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One provider button.
|
|
3
|
+
*
|
|
4
|
+
* The geometry and the per-provider colours are volto-authomatic's, keyed off
|
|
5
|
+
* the driver rather than the provider id: a site may call its Keycloak
|
|
6
|
+
* anything it likes, but it is still an OpenID Connect button.
|
|
7
|
+
*/
|
|
8
|
+
.identity-provider {
|
|
9
|
+
display: inline-flex;
|
|
10
|
+
width: var(--identity-provider-width);
|
|
11
|
+
max-width: 100%;
|
|
12
|
+
height: var(--identity-provider-height);
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
padding: 5px;
|
|
16
|
+
border: none;
|
|
17
|
+
border-radius: var(--identity-provider-radius);
|
|
18
|
+
background: var(--identity-provider-default-bg);
|
|
19
|
+
box-shadow: var(--identity-provider-shadow);
|
|
20
|
+
color: var(--identity-provider-default-color);
|
|
21
|
+
cursor: pointer;
|
|
22
|
+
font-size: 16px;
|
|
23
|
+
gap: 8px;
|
|
24
|
+
text-align: center;
|
|
25
|
+
|
|
26
|
+
&:disabled {
|
|
27
|
+
cursor: default;
|
|
28
|
+
opacity: 0.6;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&.identity-provider--github {
|
|
32
|
+
background: var(--identity-provider-github-bg);
|
|
33
|
+
color: var(--identity-provider-github-color);
|
|
34
|
+
|
|
35
|
+
&:hover:not(:disabled) {
|
|
36
|
+
background: var(--identity-provider-github-bg-hover);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
&.identity-provider--google {
|
|
41
|
+
background: var(--identity-provider-google-bg);
|
|
42
|
+
color: var(--identity-provider-google-color);
|
|
43
|
+
|
|
44
|
+
&:hover:not(:disabled) {
|
|
45
|
+
background: var(--identity-provider-google-bg-hover);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&.identity-provider--plone {
|
|
50
|
+
background: var(--identity-provider-plone-bg);
|
|
51
|
+
color: var(--identity-provider-plone-color);
|
|
52
|
+
|
|
53
|
+
&:hover:not(:disabled) {
|
|
54
|
+
background: var(--identity-provider-plone-bg-hover);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
&.identity-provider--oidc-generic {
|
|
59
|
+
background: var(--identity-provider-oidc-bg);
|
|
60
|
+
color: var(--identity-provider-oidc-color);
|
|
61
|
+
|
|
62
|
+
&:hover:not(:disabled) {
|
|
63
|
+
background: var(--identity-provider-oidc-bg-hover);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* An operator's own colours, set per provider in the control panel. The
|
|
68
|
+
rule carries no colours of its own: they arrive as an inline style, and
|
|
69
|
+
this only has to stop the driver rules above from winning. `revert`
|
|
70
|
+
rather than `unset`, so a provider styled with a background alone keeps
|
|
71
|
+
the stylesheet's text colour rather than falling back to the browser's
|
|
72
|
+
black on whatever they chose. */
|
|
73
|
+
&.identity-provider--styled {
|
|
74
|
+
background: revert;
|
|
75
|
+
color: revert;
|
|
76
|
+
|
|
77
|
+
&:hover:not(:disabled) {
|
|
78
|
+
background: revert;
|
|
79
|
+
/* The operator chose the colour; dimming it slightly is the only hover
|
|
80
|
+
this can offer without knowing what would contrast with it. */
|
|
81
|
+
filter: brightness(0.92);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.identity-provider__icon {
|
|
86
|
+
display: inline-flex;
|
|
87
|
+
width: 1.25em;
|
|
88
|
+
height: 1.25em;
|
|
89
|
+
flex: none;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
|
|
93
|
+
/* An uploaded icon is whatever size it was drawn at, and `currentColor`
|
|
94
|
+
is what makes a monochrome one take the button's text colour -- which
|
|
95
|
+
is the reason it is inlined rather than served as an image. A fill the
|
|
96
|
+
operator set on the path still wins over this. */
|
|
97
|
+
svg {
|
|
98
|
+
width: 100%;
|
|
99
|
+
height: 100%;
|
|
100
|
+
fill: currentcolor;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|