@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,173 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fireEvent, render, screen } from '../../testing';
3
+ import React from 'react';
4
+
5
+ import UserAccountPanel, { formatDate } from './UserAccountPanel';
6
+ import type { UserAccount } from '../../types';
7
+
8
+ const ACCOUNT: UserAccount = {
9
+ '@id': '/@user-account/alice',
10
+ userid: 'alice',
11
+ fullname: 'Alice Liddell',
12
+ profile_url: '/identity-profiles/alice',
13
+ identities: [
14
+ {
15
+ provider: 'github',
16
+ title: 'GitHub',
17
+ subject: '99',
18
+ created: '2026-03-02T11:40:00+00:00',
19
+ last_login: '2026-08-21T18:03:00+00:00',
20
+ provider_configured: true,
21
+ provider_enabled: true,
22
+ groups: [],
23
+ },
24
+ ],
25
+ emails: [{ address: 'alice@example.com', verified: true, preferred: true }],
26
+ last_authenticated: '2026-08-21T18:03:00+00:00',
27
+ events_total: 1,
28
+ events: [
29
+ {
30
+ event: 'authenticated',
31
+ provider: 'github',
32
+ success: true,
33
+ timestamp: '2026-08-21T18:03:00+00:00',
34
+ detail: {},
35
+ },
36
+ ],
37
+ };
38
+
39
+ function renderPanel(
40
+ props: Partial<React.ComponentProps<typeof UserAccountPanel>> = {},
41
+ ) {
42
+ render(<UserAccountPanel account={ACCOUNT} loading={false} {...props} />);
43
+ }
44
+
45
+ describe('formatDate', () => {
46
+ it('answers nothing for a missing timestamp', () => {
47
+ expect(formatDate(null)).toBeNull();
48
+ });
49
+
50
+ it('answers nothing rather than "Invalid Date"', () => {
51
+ // A backend that changed shape should not print that into a control
52
+ // panel.
53
+ expect(formatDate('not a date')).toBeNull();
54
+ });
55
+ });
56
+
57
+ /**
58
+ * Select one of the panel's tabs by its label.
59
+ *
60
+ * Only the selected panel is in the document, so a section other than the
61
+ * first is reached by pressing its tab rather than by querying for it.
62
+ */
63
+ function openTab(label: string) {
64
+ fireEvent.click(screen.getByRole('tab', { name: label }));
65
+ }
66
+
67
+ describe('UserAccountPanel', () => {
68
+ it('names the providers rather than their ids', () => {
69
+ // The first of the two questions. `@users` carries bare ids, which is not
70
+ // something to show somebody.
71
+ renderPanel();
72
+
73
+ expect(screen.getByText('GitHub')).toBeTruthy();
74
+ });
75
+
76
+ it('says when they last signed in', () => {
77
+ // The second. Nothing in Plone records it.
78
+ renderPanel();
79
+
80
+ expect(screen.getByText(/Last signed in/)).toBeTruthy();
81
+ });
82
+
83
+ it('does not claim they never signed in when the log is simply empty', () => {
84
+ // The log is bounded, so a dormant account has had its entries dropped.
85
+ renderPanel({ account: { ...ACCOUNT, last_authenticated: null } });
86
+
87
+ expect(screen.getByText('Not in the retained log')).toBeTruthy();
88
+ });
89
+
90
+ it('flags an identity whose provider is switched off', () => {
91
+ // It looks like a broken login and reads like nothing.
92
+ renderPanel({
93
+ account: {
94
+ ...ACCOUNT,
95
+ identities: [{ ...ACCOUNT.identities[0], provider_enabled: false }],
96
+ },
97
+ });
98
+
99
+ expect(screen.getByText('Disabled')).toBeTruthy();
100
+ });
101
+
102
+ it('flags an identity whose provider was removed', () => {
103
+ renderPanel({
104
+ account: {
105
+ ...ACCOUNT,
106
+ identities: [
107
+ {
108
+ ...ACCOUNT.identities[0],
109
+ provider_configured: false,
110
+ provider_enabled: false,
111
+ },
112
+ ],
113
+ },
114
+ });
115
+
116
+ expect(screen.getByText('Not configured')).toBeTruthy();
117
+ expect(screen.queryByText('Disabled')).toBeNull();
118
+ });
119
+
120
+ it('says so for a password-only account', () => {
121
+ renderPanel({ account: { ...ACCOUNT, identities: [] } });
122
+
123
+ expect(screen.getByText(/password only/)).toBeTruthy();
124
+ });
125
+
126
+ it('shows which addresses are verified', () => {
127
+ renderPanel();
128
+ openTab('Email addresses');
129
+
130
+ expect(screen.getByText('alice@example.com')).toBeTruthy();
131
+ expect(screen.getByText('Verified')).toBeTruthy();
132
+ });
133
+
134
+ it('lists the recent events', () => {
135
+ renderPanel();
136
+ openTab('Recent activity');
137
+
138
+ expect(screen.getByText('authenticated')).toBeTruthy();
139
+ });
140
+
141
+ it('opens on the sign-in methods', () => {
142
+ // Three questions about one person, and this is the one an administrator
143
+ // came for: the other two are reachable, not in the way.
144
+ renderPanel();
145
+
146
+ expect(screen.getByRole('tab', { selected: true }).textContent).toBe(
147
+ 'Sign-in methods',
148
+ );
149
+ expect(screen.queryByText('alice@example.com')).toBeNull();
150
+ });
151
+
152
+ it('keeps the last sign-in outside the tabs', () => {
153
+ // It is not one of the three sections: it is the fact the page is about,
154
+ // and hiding it behind a tab would make it the answer to a question
155
+ // nobody thought to ask.
156
+ renderPanel();
157
+ openTab('Recent activity');
158
+
159
+ expect(document.body.textContent).toContain('Last signed in');
160
+ });
161
+
162
+ it('says so while loading', () => {
163
+ renderPanel({ loading: true, account: null });
164
+
165
+ expect(screen.getByRole('status')).toBeTruthy();
166
+ });
167
+
168
+ it('says so when the read was refused', () => {
169
+ renderPanel({ account: null, error: { status: 403 } });
170
+
171
+ expect(screen.getByRole('alert')).toBeTruthy();
172
+ });
173
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * How one user gets in, and when they last did.
3
+ *
4
+ * Two questions an administrator could not ask anywhere in Plone. Which
5
+ * providers this person has configured -- `@users` carries the identities as
6
+ * bare ids, which is not something to show somebody -- and when they last
7
+ * authenticated, which nothing in Plone records at all.
8
+ *
9
+ * Presentational: the route that shows this owns the request, so it can be
10
+ * rendered in a story and in a test without a store.
11
+ *
12
+ * Three sections, and they are tabs rather than a column of headings. It is
13
+ * three separate questions about one person -- how they get in, which
14
+ * addresses are theirs, what they have done lately -- and an administrator
15
+ * opening this page has one of them in mind, not all three. The same tabs the
16
+ * sign-in methods page wears, since that page is this question asked by the
17
+ * account's owner.
18
+ * @module components/ControlPanel/UserAccountPanel
19
+ */
20
+ import React from 'react';
21
+ import { defineMessages, useIntl } from 'react-intl';
22
+ import { Tabs } from '@plone/components';
23
+ import { Tab, TabList, TabPanel } from 'react-aria-components';
24
+
25
+ // `@plone/components` ships its CSS separately from its components, so a Tabs
26
+ // rendered without this is unstyled -- the same split `PasswordForm` documents
27
+ // for TextField.css. `.identity-tabs` in the root stylesheet adds the add-on's
28
+ // own colours on top.
29
+ import '@plone/components/src/styles/basic/Tabs.css';
30
+
31
+ import type { UserAccount } from '../../types';
32
+
33
+ import './UserAccountPanel.scss';
34
+
35
+ const messages = defineMessages({
36
+ loading: { id: 'user-account-loading', defaultMessage: 'Loading…' },
37
+ failed: {
38
+ id: 'user-account-failed',
39
+ defaultMessage: 'That could not be read.',
40
+ },
41
+ lastAuthenticated: {
42
+ id: 'Last signed in',
43
+ defaultMessage: 'Last signed in',
44
+ },
45
+ never: {
46
+ id: 'user-account-never',
47
+ defaultMessage: 'Not in the retained log',
48
+ },
49
+ neverHelp: {
50
+ id: 'user-account-never-help',
51
+ defaultMessage:
52
+ 'Not the same as never: the authentication log is bounded, so an ' +
53
+ 'account dormant longer than the retention period has had its ' +
54
+ 'entries dropped.',
55
+ },
56
+ providers: { id: 'Sign-in methods', defaultMessage: 'Sign-in methods' },
57
+ noProviders: {
58
+ id: 'user-account-no-providers',
59
+ defaultMessage: 'This account signs in with a password only.',
60
+ },
61
+ linked: { id: 'user-account-linked', defaultMessage: 'linked {date}' },
62
+ lastUsed: {
63
+ id: 'user-account-last-used',
64
+ defaultMessage: 'last used {date}',
65
+ },
66
+ neverUsed: {
67
+ id: 'user-account-never-used',
68
+ defaultMessage: 'never used',
69
+ },
70
+ disabled: { id: 'Disabled', defaultMessage: 'Disabled' },
71
+ disabledHelp: {
72
+ id: 'user-account-disabled-help',
73
+ defaultMessage:
74
+ 'The provider is switched off, so this identity cannot sign anybody in.',
75
+ },
76
+ gone: { id: 'Not configured', defaultMessage: 'Not configured' },
77
+ goneHelp: {
78
+ id: 'user-account-gone-help',
79
+ defaultMessage:
80
+ 'The provider was removed while this identity was still stored ' +
81
+ 'against it. Nobody can sign in through it, and nothing was deleted.',
82
+ },
83
+ addresses: { id: 'Email addresses', defaultMessage: 'Email addresses' },
84
+ verified: { id: 'Verified', defaultMessage: 'Verified' },
85
+ noAddresses: {
86
+ id: 'user-account-no-addresses',
87
+ defaultMessage: 'This account has no profile, so it carries no addresses.',
88
+ },
89
+ events: { id: 'Recent activity', defaultMessage: 'Recent activity' },
90
+ sections: {
91
+ id: 'user-account-sections',
92
+ defaultMessage: 'What this account is made of',
93
+ },
94
+ noEvents: {
95
+ id: 'user-account-no-events',
96
+ defaultMessage: 'Nothing recorded.',
97
+ },
98
+ });
99
+
100
+ /**
101
+ * Render an ISO timestamp for a reader.
102
+ *
103
+ * `toLocaleString` rather than a format of our own: an administrator reading
104
+ * a login time wants it in the conventions of their own machine, and this
105
+ * panel has no locale opinion worth imposing.
106
+ *
107
+ * @param value An ISO 8601 timestamp, or null.
108
+ * @returns The formatted date, or null.
109
+ */
110
+ export function formatDate(value: string | null | undefined): string | null {
111
+ if (!value) {
112
+ return null;
113
+ }
114
+ const parsed = new Date(value);
115
+ // An unparseable timestamp is a backend that changed shape; showing
116
+ // "Invalid Date" in a control panel is worse than showing nothing.
117
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toLocaleString();
118
+ }
119
+
120
+ interface UserAccountPanelProps {
121
+ account?: UserAccount | null;
122
+ loading: boolean;
123
+ error?: unknown;
124
+ }
125
+
126
+ const UserAccountPanel: React.FC<UserAccountPanelProps> = ({
127
+ account,
128
+ loading,
129
+ error,
130
+ }) => {
131
+ const intl = useIntl();
132
+
133
+ if (loading) {
134
+ return (
135
+ <div className="identity-account" role="status">
136
+ {intl.formatMessage(messages.loading)}
137
+ </div>
138
+ );
139
+ }
140
+ if (error || !account) {
141
+ return (
142
+ <div className="identity-account" role="alert">
143
+ {intl.formatMessage(messages.failed)}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ const lastSeen = formatDate(account.last_authenticated);
149
+
150
+ return (
151
+ <div className="identity-account">
152
+ <p className="identity-account__last">
153
+ <strong>{intl.formatMessage(messages.lastAuthenticated)}: </strong>
154
+ {lastSeen ?? (
155
+ <span title={intl.formatMessage(messages.neverHelp)}>
156
+ {intl.formatMessage(messages.never)}
157
+ </span>
158
+ )}
159
+ </p>
160
+
161
+ <Tabs className="identity-tabs">
162
+ <TabList aria-label={intl.formatMessage(messages.sections)}>
163
+ <Tab id="providers">{intl.formatMessage(messages.providers)}</Tab>
164
+ <Tab id="addresses">{intl.formatMessage(messages.addresses)}</Tab>
165
+ <Tab id="events">{intl.formatMessage(messages.events)}</Tab>
166
+ </TabList>
167
+
168
+ <TabPanel id="providers">
169
+ {account.identities.length ? (
170
+ <ul className="identity-account__list">
171
+ {account.identities.map((identity) => (
172
+ <li key={identity.provider} data-provider={identity.provider}>
173
+ <span className="identity-account__title">
174
+ {identity.title}
175
+ </span>
176
+ {!identity.provider_configured ? (
177
+ <span
178
+ className="identity-account__badge"
179
+ data-state="gone"
180
+ title={intl.formatMessage(messages.goneHelp)}
181
+ >
182
+ {intl.formatMessage(messages.gone)}
183
+ </span>
184
+ ) : !identity.provider_enabled ? (
185
+ <span
186
+ className="identity-account__badge"
187
+ data-state="disabled"
188
+ title={intl.formatMessage(messages.disabledHelp)}
189
+ >
190
+ {intl.formatMessage(messages.disabled)}
191
+ </span>
192
+ ) : null}
193
+ <span className="identity-note">
194
+ {intl.formatMessage(messages.linked, {
195
+ date: formatDate(identity.created) ?? identity.created,
196
+ })}
197
+ {', '}
198
+ {identity.last_login
199
+ ? intl.formatMessage(messages.lastUsed, {
200
+ date: formatDate(identity.last_login),
201
+ })
202
+ : intl.formatMessage(messages.neverUsed)}
203
+ </span>
204
+ </li>
205
+ ))}
206
+ </ul>
207
+ ) : (
208
+ <p className="identity-note">
209
+ {intl.formatMessage(messages.noProviders)}
210
+ </p>
211
+ )}
212
+ </TabPanel>
213
+
214
+ <TabPanel id="addresses">
215
+ {account.emails.length ? (
216
+ <ul className="identity-account__list">
217
+ {account.emails.map((entry) => (
218
+ <li key={entry.address} data-address={entry.address}>
219
+ <span className="identity-account__title">
220
+ {entry.address}
221
+ </span>
222
+ {entry.verified ? (
223
+ <span
224
+ className="identity-account__badge"
225
+ data-state="verified"
226
+ >
227
+ {intl.formatMessage(messages.verified)}
228
+ </span>
229
+ ) : null}
230
+ </li>
231
+ ))}
232
+ </ul>
233
+ ) : (
234
+ <p className="identity-note">
235
+ {intl.formatMessage(messages.noAddresses)}
236
+ </p>
237
+ )}
238
+ </TabPanel>
239
+
240
+ <TabPanel id="events">
241
+ {account.events.length ? (
242
+ <ul className="identity-account__list">
243
+ {account.events.map((event, index) => (
244
+ <li
245
+ // Nothing in an audit entry is unique -- two sign-ins a second
246
+ // apart through the same provider are identical but for the
247
+ // timestamp -- and the list is a fixed newest-first slice that
248
+ // is never reordered, so the position is the honest key.
249
+ key={`${event.timestamp}-${index}`}
250
+ data-event={event.event}
251
+ data-success={String(event.success)}
252
+ >
253
+ <span className="identity-account__title">{event.event}</span>
254
+ <span className="identity-note">
255
+ {event.provider}
256
+ {' · '}
257
+ {formatDate(event.timestamp) ?? event.timestamp}
258
+ </span>
259
+ </li>
260
+ ))}
261
+ </ul>
262
+ ) : (
263
+ <p className="identity-note">
264
+ {intl.formatMessage(messages.noEvents)}
265
+ </p>
266
+ )}
267
+ </TabPanel>
268
+ </Tabs>
269
+ </div>
270
+ );
271
+ };
272
+
273
+ export default UserAccountPanel;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The one line shown while the first-login decision is being made.
3
+ *
4
+ * Inside `LoginPanel`, like the callback: the card and its centring are the
5
+ * panel's, and this only centres the line within it.
6
+ */
7
+ .identity-first-login {
8
+ padding: var(--identity-gap) 0;
9
+ margin: 0;
10
+ color: var(--identity-muted);
11
+ text-align: center;
12
+ }
@@ -0,0 +1,50 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+
3
+ import FirstLogin from './FirstLogin';
4
+ import { LOADED, LOADING, withStore } from '../../stories/fixtures';
5
+
6
+ const meta: Meta<typeof FirstLogin> = {
7
+ title: 'Identity/FirstLogin',
8
+ component: FirstLogin,
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof FirstLogin>;
13
+
14
+ export const Deciding: Story = {
15
+ decorators: [withStore({ myProfile: LOADING })],
16
+ };
17
+
18
+ /** A user whose Profile still needs filling in. */
19
+ export const ProfileIncomplete: Story = {
20
+ decorators: [
21
+ withStore({
22
+ myProfile: {
23
+ ...LOADED,
24
+ data: {
25
+ '@id': '/@my-profile',
26
+ userid: 'alice',
27
+ profile: 'https://example.org/identity-profiles/alice',
28
+ review_state: 'incomplete',
29
+ },
30
+ },
31
+ }),
32
+ ],
33
+ };
34
+
35
+ /** A user with no Profile: there is nothing to complete. */
36
+ export const NoProfile: Story = {
37
+ decorators: [
38
+ withStore({
39
+ myProfile: {
40
+ ...LOADED,
41
+ data: {
42
+ '@id': '/@my-profile',
43
+ userid: 'alice',
44
+ profile: null,
45
+ review_state: null,
46
+ },
47
+ },
48
+ }),
49
+ ],
50
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * First-login routing.
3
+ *
4
+ * A user who has just signed in for the first time has a Profile in the
5
+ * `incomplete` state and nothing in it. Sending them straight to wherever
6
+ * they were heading means they never see it; asking every user to fill one in
7
+ * on every login would be worse. This route asks the backend which of the two
8
+ * applies and navigates accordingly.
9
+ *
10
+ * Wire it as the destination a successful sign-in navigates to, passing the
11
+ * user's original target along:
12
+ *
13
+ * ```tsx
14
+ * <Callback onToken={(token, cameFrom) => {
15
+ * dispatch(loginSuccess(token));
16
+ * history.push(`/first-login?return_url=${encodeURIComponent(cameFrom)}`);
17
+ * }} />
18
+ * ```
19
+ *
20
+ * It is a separate route rather than logic inside `Callback` so that a site
21
+ * that does not want the step can simply not use it, and so the decision is
22
+ * reachable from a magic-link sign-in as well as an OAuth one.
23
+ * @module components/FirstLogin/FirstLogin
24
+ */
25
+ import React, { useEffect, useRef } from 'react';
26
+ import { useDispatch, useSelector } from 'react-redux';
27
+ import { useHistory, useLocation } from 'react-router-dom';
28
+ import { defineMessages, useIntl } from 'react-intl';
29
+
30
+ import { getMyProfile } from '../../actions';
31
+ import { afterLogin } from '../../helpers/firstLogin';
32
+ import { returnUrl } from '../../helpers/returnUrl';
33
+ import LoginPanel from '../Login/LoginPanel';
34
+
35
+ import './FirstLogin.scss';
36
+
37
+ const messages = defineMessages({
38
+ title: { id: 'Log in', defaultMessage: 'Log in' },
39
+ working: { id: 'Signing you in', defaultMessage: 'Signing you in…' },
40
+ });
41
+
42
+ interface FirstLoginProps {
43
+ /** Backend base URL, when it differs from the frontend's. */
44
+ apiPath?: string;
45
+ }
46
+
47
+ const FirstLogin: React.FC<FirstLoginProps> = ({ apiPath = '' }) => {
48
+ const intl = useIntl();
49
+ const dispatch = useDispatch();
50
+ const history = useHistory();
51
+ const location = useLocation();
52
+ const dispatched = useRef(false);
53
+ const navigated = useRef(false);
54
+
55
+ const profile = useSelector((state: any) => state.myProfile);
56
+
57
+ useEffect(() => {
58
+ if (dispatched.current) {
59
+ return;
60
+ }
61
+ dispatched.current = true;
62
+ dispatch(getMyProfile());
63
+ }, [dispatch]);
64
+
65
+ useEffect(() => {
66
+ if (navigated.current) {
67
+ return;
68
+ }
69
+ // An error here is not a reason to strand the user on a spinner: they are
70
+ // signed in either way, and the worst case of carrying on is that they
71
+ // fill their profile in later.
72
+ if (!profile?.loaded && !profile?.error) {
73
+ return;
74
+ }
75
+ navigated.current = true;
76
+ const fallback = returnUrl(location.search, '/');
77
+ history.replace(
78
+ profile?.error ? fallback : afterLogin(profile.data, fallback, apiPath),
79
+ );
80
+ }, [profile, history, location.search, apiPath]);
81
+
82
+ return (
83
+ // The same card the callback and the login page are: this is the last
84
+ // step of the same flow, and a bare line of text here sat against the
85
+ // left edge of the window.
86
+ <LoginPanel title={intl.formatMessage(messages.title)}>
87
+ <p className="identity-first-login" role="status">
88
+ {intl.formatMessage(messages.working)}
89
+ </p>
90
+ </LoginPanel>
91
+ );
92
+ };
93
+
94
+ export default FirstLogin;
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+
3
+ import Identities from './Identities';
4
+ import {
5
+ IDENTITIES,
6
+ LOADED,
7
+ LOADING,
8
+ PROFILE_EMAILS,
9
+ PROVIDERS,
10
+ withStore,
11
+ } from '../../stories/fixtures';
12
+
13
+ const meta: Meta<typeof Identities> = {
14
+ title: 'Identity/Identities/Identities',
15
+ component: Identities,
16
+ };
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof Identities>;
20
+
21
+ const base = {
22
+ identities: { ...LOADED, data: IDENTITIES },
23
+ // What the page offers to add is the backend's own `available`, which is
24
+ // not the login screen's listing: a provider taken off the login page is
25
+ // still one an existing user may attach.
26
+ linkableProviders: { ...LOADED, data: PROVIDERS },
27
+ myProfile: {
28
+ ...LOADED,
29
+ data: {
30
+ '@id': '/@my-profile',
31
+ userid: 'erico',
32
+ profile: '/identity-profiles/erico',
33
+ review_state: 'complete',
34
+ missing: [],
35
+ emails: PROFILE_EMAILS,
36
+ },
37
+ },
38
+ identityLinking: {},
39
+ identityUnlink: {},
40
+ };
41
+
42
+ /** Where a signed-in user manages their own ways in. */
43
+ export const Default: Story = { decorators: [withStore(base)] };
44
+
45
+ export const Loading: Story = {
46
+ decorators: [withStore({ ...base, identities: { ...LOADING, data: [] } })],
47
+ };
48
+
49
+ export const Unlinking: Story = {
50
+ decorators: [withStore({ ...base, identityUnlink: LOADING })],
51
+ };