@plone-collective/volto-identity 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.changelog.draft +261 -0
  2. package/.release-it.json +34 -0
  3. package/CHANGELOG.md +269 -0
  4. package/LICENSE +21 -0
  5. package/README.md +228 -0
  6. package/babel.config.js +17 -0
  7. package/locales/de/LC_MESSAGES/volto.po +1162 -0
  8. package/locales/en/LC_MESSAGES/volto.po +1162 -0
  9. package/locales/es/LC_MESSAGES/volto.po +1169 -0
  10. package/locales/pt_BR/LC_MESSAGES/volto.po +1167 -0
  11. package/locales/volto.pot +1164 -0
  12. package/news/.changelog_template.jinja +15 -0
  13. package/news/.gitkeep +0 -0
  14. package/package.json +66 -0
  15. package/public/.gitkeep +0 -0
  16. package/src/actions/actions.test.ts +226 -0
  17. package/src/actions/index.ts +530 -0
  18. package/src/components/.gitkeep +0 -0
  19. package/src/components/Applications/Applications.tsx +169 -0
  20. package/src/components/Applications/ApplicationsPanel.scss +59 -0
  21. package/src/components/Applications/ApplicationsPanel.stories.tsx +82 -0
  22. package/src/components/Applications/ApplicationsPanel.test.tsx +235 -0
  23. package/src/components/Applications/ApplicationsPanel.tsx +342 -0
  24. package/src/components/Callback/Callback.scss +17 -0
  25. package/src/components/Callback/Callback.stories.tsx +43 -0
  26. package/src/components/Callback/Callback.test.tsx +262 -0
  27. package/src/components/Callback/Callback.tsx +170 -0
  28. package/src/components/Consent/Consent.test.tsx +75 -0
  29. package/src/components/Consent/Consent.tsx +98 -0
  30. package/src/components/Consent/ConsentPanel.scss +37 -0
  31. package/src/components/Consent/ConsentPanel.stories.tsx +80 -0
  32. package/src/components/Consent/ConsentPanel.test.tsx +138 -0
  33. package/src/components/Consent/ConsentPanel.tsx +162 -0
  34. package/src/components/ControlPanel/ClientsControlPanel.stories.tsx +38 -0
  35. package/src/components/ControlPanel/ClientsControlPanel.tsx +321 -0
  36. package/src/components/ControlPanel/ClientsPanel.scss +21 -0
  37. package/src/components/ControlPanel/ClientsPanel.stories.tsx +71 -0
  38. package/src/components/ControlPanel/ClientsPanel.test.tsx +267 -0
  39. package/src/components/ControlPanel/ClientsPanel.tsx +386 -0
  40. package/src/components/ControlPanel/ConfirmModal.stories.tsx +35 -0
  41. package/src/components/ControlPanel/ConfirmModal.test.tsx +66 -0
  42. package/src/components/ControlPanel/ConfirmModal.tsx +68 -0
  43. package/src/components/ControlPanel/ProviderForm.stories.tsx +107 -0
  44. package/src/components/ControlPanel/ProvidersControlPanel.scss +26 -0
  45. package/src/components/ControlPanel/ProvidersControlPanel.stories.tsx +88 -0
  46. package/src/components/ControlPanel/ProvidersControlPanel.tsx +600 -0
  47. package/src/components/ControlPanel/RenderUsers.stories.tsx +112 -0
  48. package/src/components/ControlPanel/RenderUsers.test.tsx +145 -0
  49. package/src/components/ControlPanel/RenderUsers.tsx +263 -0
  50. package/src/components/ControlPanel/SecretReveal.scss +32 -0
  51. package/src/components/ControlPanel/SecretReveal.stories.tsx +33 -0
  52. package/src/components/ControlPanel/SecretReveal.test.tsx +101 -0
  53. package/src/components/ControlPanel/SecretReveal.tsx +105 -0
  54. package/src/components/ControlPanel/UserAccount.stories.tsx +69 -0
  55. package/src/components/ControlPanel/UserAccount.test.tsx +117 -0
  56. package/src/components/ControlPanel/UserAccount.tsx +125 -0
  57. package/src/components/ControlPanel/UserAccountPanel.scss +38 -0
  58. package/src/components/ControlPanel/UserAccountPanel.stories.tsx +77 -0
  59. package/src/components/ControlPanel/UserAccountPanel.test.tsx +173 -0
  60. package/src/components/ControlPanel/UserAccountPanel.tsx +273 -0
  61. package/src/components/FirstLogin/FirstLogin.scss +12 -0
  62. package/src/components/FirstLogin/FirstLogin.stories.tsx +50 -0
  63. package/src/components/FirstLogin/FirstLogin.tsx +94 -0
  64. package/src/components/Identities/Identities.stories.tsx +51 -0
  65. package/src/components/Identities/Identities.test.tsx +134 -0
  66. package/src/components/Identities/Identities.tsx +217 -0
  67. package/src/components/Identities/IdentitiesList.scss +49 -0
  68. package/src/components/Identities/IdentitiesList.stories.tsx +71 -0
  69. package/src/components/Identities/IdentitiesList.test.tsx +218 -0
  70. package/src/components/Identities/IdentitiesList.tsx +207 -0
  71. package/src/components/Identities/ProfileEmails.scss +62 -0
  72. package/src/components/Identities/ProfileEmails.stories.tsx +75 -0
  73. package/src/components/Identities/ProfileEmails.test.tsx +132 -0
  74. package/src/components/Identities/ProfileEmails.tsx +217 -0
  75. package/src/components/Login/Login.stories.tsx +63 -0
  76. package/src/components/Login/Login.test.tsx +189 -0
  77. package/src/components/Login/Login.tsx +163 -0
  78. package/src/components/Login/LoginForm.scss +39 -0
  79. package/src/components/Login/LoginForm.stories.tsx +112 -0
  80. package/src/components/Login/LoginForm.test.tsx +483 -0
  81. package/src/components/Login/LoginForm.tsx +321 -0
  82. package/src/components/Login/LoginOverlay.scss +44 -0
  83. package/src/components/Login/LoginOverlay.stories.tsx +73 -0
  84. package/src/components/Login/LoginOverlay.test.tsx +98 -0
  85. package/src/components/Login/LoginOverlay.tsx +107 -0
  86. package/src/components/Login/LoginPanel.scss +50 -0
  87. package/src/components/Login/LoginPanel.stories.tsx +40 -0
  88. package/src/components/Login/LoginPanel.tsx +60 -0
  89. package/src/components/Login/MagicLinkForm.scss +25 -0
  90. package/src/components/Login/MagicLinkForm.stories.tsx +27 -0
  91. package/src/components/Login/MagicLinkForm.tsx +133 -0
  92. package/src/components/Login/PasswordForm.scss +82 -0
  93. package/src/components/Login/PasswordForm.stories.tsx +26 -0
  94. package/src/components/Login/PasswordForm.tsx +159 -0
  95. package/src/components/Login/ProviderButton.scss +103 -0
  96. package/src/components/Login/ProviderButton.stories.tsx +74 -0
  97. package/src/components/Login/ProviderButton.test.tsx +99 -0
  98. package/src/components/Login/ProviderButton.tsx +97 -0
  99. package/src/components/ProfileGate/ProfileGate.stories.tsx +118 -0
  100. package/src/components/ProfileGate/ProfileGate.test.tsx +370 -0
  101. package/src/components/ProfileGate/ProfileGate.tsx +172 -0
  102. package/src/components/Toolbar/PersonalTools.stories.tsx +111 -0
  103. package/src/components/Toolbar/PersonalTools.test.tsx +165 -0
  104. package/src/components/Toolbar/PersonalTools.tsx +177 -0
  105. package/src/components/Toolbar/Toolbar.jsx +766 -0
  106. package/src/components/UserMenu/ApplicationsMenuItem.stories.tsx +62 -0
  107. package/src/components/UserMenu/ApplicationsMenuItem.test.tsx +109 -0
  108. package/src/components/UserMenu/ApplicationsMenuItem.tsx +82 -0
  109. package/src/components/UserMenu/IdentitiesMenuItem.stories.tsx +35 -0
  110. package/src/components/UserMenu/IdentitiesMenuItem.test.tsx +103 -0
  111. package/src/components/UserMenu/IdentitiesMenuItem.tsx +34 -0
  112. package/src/components/UserMenu/MenuItem.stories.tsx +32 -0
  113. package/src/components/UserMenu/MenuItem.tsx +48 -0
  114. package/src/components/UserMenu/ProfileMenuItem.stories.tsx +76 -0
  115. package/src/components/UserMenu/ProfileMenuItem.test.tsx +90 -0
  116. package/src/components/UserMenu/ProfileMenuItem.tsx +60 -0
  117. package/src/components/UserMenu/UserAvatar.scss +23 -0
  118. package/src/components/UserMenu/UserAvatar.stories.tsx +121 -0
  119. package/src/components/UserMenu/UserAvatar.test.tsx +120 -0
  120. package/src/components/UserMenu/UserAvatar.tsx +119 -0
  121. package/src/components/UserMenu/UserMenuPlugs.stories.tsx +101 -0
  122. package/src/components/UserMenu/UserMenuPlugs.test.tsx +202 -0
  123. package/src/components/UserMenu/UserMenuPlugs.tsx +142 -0
  124. package/src/components/UserMenu/UserProfileLoader.test.tsx +211 -0
  125. package/src/components/UserMenu/UserProfileLoader.tsx +54 -0
  126. package/src/components/Views/GroupView.scss +36 -0
  127. package/src/components/Views/GroupView.stories.tsx +98 -0
  128. package/src/components/Views/GroupView.test.tsx +137 -0
  129. package/src/components/Views/GroupView.tsx +178 -0
  130. package/src/components/Views/ProfileView.scss +27 -0
  131. package/src/components/Views/ProfileView.stories.tsx +34 -0
  132. package/src/components/Views/ProfileView.test.tsx +82 -0
  133. package/src/components/Views/ProfileView.tsx +100 -0
  134. package/src/components/Widgets/ProviderIconWidget.scss +21 -0
  135. package/src/components/Widgets/ProviderIconWidget.stories.tsx +30 -0
  136. package/src/components/Widgets/ProviderIconWidget.test.tsx +55 -0
  137. package/src/components/Widgets/ProviderIconWidget.tsx +143 -0
  138. package/src/config/menu.ts +35 -0
  139. package/src/config/reducers.ts +7 -0
  140. package/src/config/routes.test.ts +177 -0
  141. package/src/config/routes.ts +143 -0
  142. package/src/config/settings.test.ts +34 -0
  143. package/src/config/settings.ts +72 -0
  144. package/src/config/views.ts +30 -0
  145. package/src/config/widgets.ts +25 -0
  146. package/src/constants/ActionTypes.ts +42 -0
  147. package/src/constants/vocabularies.ts +20 -0
  148. package/src/customizations/volto/components/manage/Controlpanels/Users/RenderUsers.tsx +23 -0
  149. package/src/customizations/volto/components/manage/Toolbar/PersonalTools.tsx +21 -0
  150. package/src/customizations/volto/components/manage/Toolbar/Toolbar.jsx +21 -0
  151. package/src/helpers/avatar.test.ts +79 -0
  152. package/src/helpers/avatar.ts +85 -0
  153. package/src/helpers/callback.test.ts +60 -0
  154. package/src/helpers/callback.ts +71 -0
  155. package/src/helpers/clientSchema.test.ts +182 -0
  156. package/src/helpers/clientSchema.ts +181 -0
  157. package/src/helpers/firstLogin.test.ts +90 -0
  158. package/src/helpers/firstLogin.ts +76 -0
  159. package/src/helpers/groupmap.test.ts +81 -0
  160. package/src/helpers/groupmap.ts +50 -0
  161. package/src/helpers/identities.ts +22 -0
  162. package/src/helpers/navigate.test.ts +132 -0
  163. package/src/helpers/navigate.ts +126 -0
  164. package/src/helpers/profileGate.test.ts +150 -0
  165. package/src/helpers/profileGate.ts +191 -0
  166. package/src/helpers/profileSource.ts +41 -0
  167. package/src/helpers/propertymap.test.ts +71 -0
  168. package/src/helpers/propertymap.ts +43 -0
  169. package/src/helpers/providerSchema.test.ts +410 -0
  170. package/src/helpers/providerSchema.ts +362 -0
  171. package/src/helpers/returnUrl.test.ts +42 -0
  172. package/src/helpers/returnUrl.ts +41 -0
  173. package/src/helpers/rowmap.ts +110 -0
  174. package/src/helpers/showPloneLogin.test.ts +78 -0
  175. package/src/helpers/showPloneLogin.ts +55 -0
  176. package/src/helpers/token.test.ts +62 -0
  177. package/src/helpers/token.ts +71 -0
  178. package/src/index.ts +22 -0
  179. package/src/reducers/index.ts +369 -0
  180. package/src/reducers/reducers.test.ts +369 -0
  181. package/src/stories/fixtures.tsx +640 -0
  182. package/src/storybook/withPage.test.tsx +90 -0
  183. package/src/storybook/withPage.tsx +110 -0
  184. package/src/storybook/withUserMenu.test.tsx +78 -0
  185. package/src/storybook/withUserMenu.tsx +136 -0
  186. package/src/styles.css +262 -0
  187. package/src/testing.tsx +86 -0
  188. package/src/types.ts +441 -0
  189. package/towncrier.toml +38 -0
  190. package/tsconfig.json +22 -0
  191. package/vitest.config.mjs +47 -0
@@ -0,0 +1,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
+ }