@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,72 @@
1
+ import type { ConfigType } from '@plone/registry';
2
+ import ProfileGate from '../components/ProfileGate/ProfileGate';
3
+
4
+ declare module '@plone/types' {
5
+ export interface SettingsConfig {
6
+ /** Whether Plone's own username/password form is offered as well. */
7
+ identityShowPloneLogin: boolean;
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Read a boolean out of the environment.
13
+ *
14
+ * Only the words are accepted, and anything unset falls back to the default.
15
+ * Deliberately not `Boolean(value)`: that reads the string `"false"` as true,
16
+ * which turns an operator switching the password form *off* into a site that
17
+ * still shows it.
18
+ *
19
+ * @param value The raw environment value.
20
+ * @param fallback What an unset variable means.
21
+ * @returns The decision.
22
+ */
23
+ export function asBoolean(
24
+ value: string | undefined,
25
+ fallback: boolean,
26
+ ): boolean {
27
+ if (value === undefined || value === '') {
28
+ return fallback;
29
+ }
30
+ return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
31
+ }
32
+
33
+ export default function install(config: ConfigType) {
34
+ // Off by default: a site installing this add-on has external providers,
35
+ // and leaving the password form up next to them invites people to create a
36
+ // second way into the same account.
37
+ //
38
+ // An identity provider built on this package is the case that wants it on:
39
+ // its users *are* local, and it is the site people sign in to in order to
40
+ // sign in elsewhere.
41
+ //
42
+ // **This is only the default.** The deployment answer is
43
+ // `RAZZLE_IDENTITY_SHOW_PLONE_LOGIN`, read at *run* time by the Login
44
+ // component -- see `showPloneLogin` there. It is not read here, and that is
45
+ // the whole point: written out literally, `process.env.RAZZLE_...` is
46
+ // substituted into the browser bundle by webpack's DefinePlugin while
47
+ // `pnpm build` runs, which makes the value a property of the image. Two
48
+ // sites wanting two answers then need two images.
49
+ //
50
+ // A project that wants a different default overrides this setting in its
51
+ // own configuration, and the environment still wins over it.
52
+ config.settings.identityShowPloneLogin = false;
53
+
54
+ // The required-information gate, on every route.
55
+ //
56
+ // The backend has one too, and it lets `plone.restapi` requests through on
57
+ // purpose: Volto fetches the edit form over the API, so gating those would
58
+ // break the page the user is being sent to. Every navigation in this app is
59
+ // such a request, which means the backend gate never fires here and this is
60
+ // the one that does.
61
+ //
62
+ // An empty `match` mounts it everywhere, which is the point: a gate that
63
+ // only covers some routes is a list of ways around it.
64
+ config.settings.appExtras = [
65
+ ...(config.settings.appExtras ?? []),
66
+ // `props` is empty and still spelled out: `AppExtras` spreads it, so
67
+ // omitting it renders the same, but Volto's `Settings.d.ts` declares it
68
+ // required and the gate is the only entry this add-on adds.
69
+ { match: '', component: ProfileGate, props: {} },
70
+ ];
71
+ return config;
72
+ }
@@ -0,0 +1,30 @@
1
+ import type { ConfigType } from '@plone/registry';
2
+ import GroupView from '../components/Views/GroupView';
3
+ import ProfileView from '../components/Views/ProfileView';
4
+
5
+ /** Portal type of a user's Profile. */
6
+ export const PROFILE_TYPE = 'UserProfile';
7
+
8
+ /** Portal type of a group. */
9
+ export const GROUP_TYPE = 'UserGroup';
10
+
11
+ /**
12
+ * Give the two principal types views of their own.
13
+ *
14
+ * Both are content, so without this they rendered through Volto's default
15
+ * view: a title and a body that is empty, because neither type has rich text.
16
+ * What each page has to say is on fields the default view does not know about
17
+ * -- a person's picture, a group's nesting -- and for a group most of it is
18
+ * not on the content object at all.
19
+ *
20
+ * Registered by portal type rather than as a layout, so a site that files its
21
+ * users under its own type keeps its own view and gets neither of these.
22
+ */
23
+ export default function install(config: ConfigType) {
24
+ config.views.contentTypesViews = {
25
+ ...config.views.contentTypesViews,
26
+ [PROFILE_TYPE]: ProfileView,
27
+ [GROUP_TYPE]: GroupView,
28
+ };
29
+ return config;
30
+ }
@@ -0,0 +1,25 @@
1
+ import type { ConfigType } from '@plone/registry';
2
+ import ProviderIconWidget from '../components/Widgets/ProviderIconWidget';
3
+
4
+ /**
5
+ * The one widget this add-on supplies, and the reason it supplies only one.
6
+ *
7
+ * A widget is a component, so it can only live in the frontend. *Which* widget
8
+ * a field uses is a different question, and that one belongs to the backend:
9
+ * every field this add-on renders says so itself, through
10
+ * `directives.widget(..., frontendOptions={"widget": ...})`, and Volto looks
11
+ * the name up in this map. So the provider form asks for `color_picker` and
12
+ * `token` and gets Volto's own, and asks for `provider_icon` and gets this.
13
+ *
14
+ * That split is what the schema rewrite was about (Érico, 2026-08-29): the
15
+ * backend decides what a field is and how it should be edited, and the
16
+ * frontend supplies the component when it has one Volto does not.
17
+ */
18
+ export default function install(config: ConfigType) {
19
+ config.widgets.widget = {
20
+ ...config.widgets.widget,
21
+ provider_icon: ProviderIconWidget,
22
+ };
23
+
24
+ return config;
25
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Action types for the identity add-on.
3
+ *
4
+ * Namespaced with the package name: Volto merges every add-on's reducers into
5
+ * one store, and a bare name like `LOGIN` would collide sooner or later.
6
+ * @module constants/ActionTypes
7
+ */
8
+
9
+ export const LIST_LOGIN_PROVIDERS = 'IDENTITY_LIST_LOGIN_PROVIDERS';
10
+ export const START_PROVIDER_LOGIN = 'IDENTITY_START_PROVIDER_LOGIN';
11
+ export const COMPLETE_CALLBACK = 'IDENTITY_COMPLETE_CALLBACK';
12
+ export const SEND_MAGIC_LINK = 'IDENTITY_SEND_MAGIC_LINK';
13
+ export const CONFIRM_MAGIC_LINK = 'IDENTITY_CONFIRM_MAGIC_LINK';
14
+
15
+ export const LIST_IDENTITIES = 'IDENTITY_LIST_IDENTITIES';
16
+ export const START_LINKING = 'IDENTITY_START_LINKING';
17
+ export const UNLINK_IDENTITY = 'IDENTITY_UNLINK_IDENTITY';
18
+
19
+ export const LIST_GROUP_MEMBERS = 'IDENTITY_LIST_GROUP_MEMBERS';
20
+ export const GET_USER_ACCOUNT = 'IDENTITY_GET_USER_ACCOUNT';
21
+
22
+ export const GET_MY_PROFILE = 'IDENTITY_GET_MY_PROFILE';
23
+ export const SET_PREFERRED_EMAIL = 'IDENTITY_SET_PREFERRED_EMAIL';
24
+ export const GET_USER_PROFILE = 'IDENTITY_GET_USER_PROFILE';
25
+
26
+ export const LIST_DRIVERS = 'IDENTITY_LIST_DRIVERS';
27
+ export const LIST_PROVIDERS = 'IDENTITY_LIST_PROVIDERS';
28
+ export const CREATE_PROVIDER = 'IDENTITY_CREATE_PROVIDER';
29
+ export const UPDATE_PROVIDER = 'IDENTITY_UPDATE_PROVIDER';
30
+ export const DELETE_PROVIDER = 'IDENTITY_DELETE_PROVIDER';
31
+ export const TEST_PROVIDER = 'IDENTITY_TEST_PROVIDER';
32
+
33
+ export const LIST_CLIENTS = 'IDENTITY_LIST_CLIENTS';
34
+ export const CREATE_CLIENT = 'IDENTITY_CREATE_CLIENT';
35
+ export const UPDATE_CLIENT = 'IDENTITY_UPDATE_CLIENT';
36
+ export const DELETE_CLIENT = 'IDENTITY_DELETE_CLIENT';
37
+ export const ROTATE_CLIENT_SECRET = 'IDENTITY_ROTATE_CLIENT_SECRET';
38
+ export const LIST_KEYS = 'IDENTITY_LIST_KEYS';
39
+ export const GET_CONSENT_REQUEST = 'IDENTITY_GET_CONSENT_REQUEST';
40
+ export const LIST_GRANTS = 'IDENTITY_LIST_GRANTS';
41
+ export const WITHDRAW_GRANT = 'IDENTITY_WITHDRAW_GRANT';
42
+ export const ROTATE_KEY = 'IDENTITY_ROTATE_KEY';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Vocabularies this add-on reads.
3
+ * @module constants/vocabularies
4
+ */
5
+
6
+ /**
7
+ * The member fields a provider claim can be written to.
8
+ *
9
+ * Served by the backend from the site's live user schema, so it follows
10
+ * whatever the **User Schema** control panel was used to add.
11
+ */
12
+ export const USER_FIELDS_VOCABULARY = 'pas.plugins.identity.UserFields';
13
+
14
+ /**
15
+ * The groups a provider's group can be mapped onto.
16
+ *
17
+ * Every group PAS knows, whichever plugin answers for it, minus the virtual
18
+ * ones nobody is explicitly a member of.
19
+ */
20
+ export const GROUPS_VOCABULARY = 'pas.plugins.identity.Groups';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Volto's users-control-panel row, replaced by this add-on's.
3
+ *
4
+ * Shadows `@plone/volto/components/manage/Controlpanels/Users/RenderUsers`
5
+ * (Volto 19.3.0).
6
+ *
7
+ * WHY IT IS SHADOWED. The row's {guilabel}`Edit` action is built inline and
8
+ * Volto has no extension point in it, so where that action leads cannot be
9
+ * changed without replacing the component. It led to the wrong form on the
10
+ * wrong store for every user whose fields live in a Profile, which is most of
11
+ * them.
12
+ *
13
+ * No code lives here. The component is
14
+ * `components/ControlPanel/RenderUsers`, with its tests and its stories
15
+ * beside it, and this file is the one line that puts it in Volto's place --
16
+ * a shadowed path is a wiring decision, not somewhere to keep an
17
+ * implementation nothing can import by name.
18
+ *
19
+ * @module customizations/volto/components/manage/Controlpanels/Users/RenderUsers
20
+ */
21
+ import RenderUsers from '@plone-collective/volto-identity/components/ControlPanel/RenderUsers';
22
+
23
+ export default RenderUsers;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Volto's personal-tools menu, replaced by this add-on's.
3
+ *
4
+ * Shadows `@plone/volto/components/manage/Toolbar/PersonalTools`
5
+ * (Volto 19.3.0).
6
+ *
7
+ * WHY IT IS SHADOWED. Volto has no extension point here. The only pluggable
8
+ * is `toolbar-user-menu`, at the end of the menu *list*, so the avatar block
9
+ * above it cannot be touched without replacing this component -- and the menu
10
+ * entries themselves cannot be reordered or removed at all.
11
+ *
12
+ * No code lives here. The component is `components/Toolbar/PersonalTools`,
13
+ * with its tests and its stories beside it, and this file is the one line
14
+ * that puts it in Volto's place -- a shadowed path is a wiring decision, not
15
+ * somewhere to keep an implementation nothing can import by name.
16
+ *
17
+ * @module customizations/volto/components/manage/Toolbar/PersonalTools
18
+ */
19
+ import PersonalTools from '@plone-collective/volto-identity/components/Toolbar/PersonalTools';
20
+
21
+ export default PersonalTools;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Volto's toolbar, replaced by this add-on's.
3
+ *
4
+ * Shadows `@plone/volto/components/manage/Toolbar/Toolbar` (Volto 19.3.0).
5
+ *
6
+ * WHY IT IS SHADOWED. Volto has no extension point for the toolbar's user
7
+ * button. `toolbar-personal` is a DOM id on a button in this component, not a
8
+ * pluggable, so the icon it draws cannot be replaced without replacing the
9
+ * component around it -- and that icon is the generic `user.svg`, which says
10
+ * that somebody is signed in rather than who.
11
+ *
12
+ * No code lives here. The component is `components/Toolbar/Toolbar`, and this
13
+ * file is the one line that puts it in Volto's place -- a shadowed path is a
14
+ * wiring decision, not somewhere to keep an implementation nothing can import
15
+ * by name.
16
+ *
17
+ * @module customizations/volto/components/manage/Toolbar/Toolbar
18
+ */
19
+ import Toolbar from '@plone-collective/volto-identity/components/Toolbar/Toolbar';
20
+
21
+ export default Toolbar;
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { AVATAR_COLORS, colorFor, initialsFor } from './avatar';
4
+
5
+ describe('initialsFor', () => {
6
+ it('takes the first letter of the first and last words', () => {
7
+ expect(initialsFor('Érico Andrei')).toBe('ÉA');
8
+ });
9
+
10
+ it('skips the middle names rather than the surname', () => {
11
+ // ÉA, not ÉD: a middle name must not push the surname out.
12
+ expect(initialsFor('Érico de Andrei')).toBe('ÉA');
13
+ });
14
+
15
+ it('takes two letters from a single name', () => {
16
+ expect(initialsFor('madonna')).toBe('MA');
17
+ });
18
+
19
+ it('takes one letter from a single-letter name', () => {
20
+ expect(initialsFor('x')).toBe('X');
21
+ });
22
+
23
+ it('uppercases for the locale', () => {
24
+ expect(initialsFor('érico andrei')).toBe('ÉA');
25
+ });
26
+
27
+ it('ignores words that carry no letter', () => {
28
+ // A userid like `alice (admin)` should not yield `A(`.
29
+ expect(initialsFor('alice (admin)')).toBe('AA');
30
+ // `bob - 2` has exactly one lettered word, so it is the single-name
31
+ // case: two letters from `bob`, not `B` twice.
32
+ expect(initialsFor('bob - 2')).toBe('BO');
33
+ });
34
+
35
+ it('answers nothing when there is no name at all', () => {
36
+ // The component draws a plain circle rather than an empty box.
37
+ expect(initialsFor('')).toBe('');
38
+ expect(initialsFor(' ')).toBe('');
39
+ expect(initialsFor(undefined)).toBe('');
40
+ expect(initialsFor(null)).toBe('');
41
+ });
42
+
43
+ it('answers nothing for a name with no letters in it', () => {
44
+ expect(initialsFor('123 456')).toBe('');
45
+ });
46
+
47
+ it('handles a name outside the Latin alphabet', () => {
48
+ expect(initialsFor('Ада Лавлейс')).toBe('АЛ');
49
+ });
50
+ });
51
+
52
+ describe('colorFor', () => {
53
+ it('always picks from the palette', () => {
54
+ for (const userid of ['alice', 'bob', '', 'a'.repeat(200), 'ürico']) {
55
+ expect(AVATAR_COLORS).toContain(colorFor(userid) as never);
56
+ }
57
+ });
58
+
59
+ it('is stable for the same user', () => {
60
+ // The point of deriving it: the same person is the same colour on every
61
+ // visit and every device, with nothing stored anywhere.
62
+ expect(colorFor('alice')).toBe(colorFor('alice'));
63
+ });
64
+
65
+ it('separates users who sit next to each other', () => {
66
+ // Not a guarantee for every pair -- ten colours cannot be -- but
67
+ // adjacent userids landing on one colour would be visible immediately.
68
+ const colors = new Set(
69
+ ['alice', 'bob', 'carol', 'dave'].map((id) => colorFor(id)),
70
+ );
71
+
72
+ expect(colors.size).toBeGreaterThan(1);
73
+ });
74
+
75
+ it('answers for a user with no id', () => {
76
+ expect(AVATAR_COLORS).toContain(colorFor(undefined) as never);
77
+ expect(AVATAR_COLORS).toContain(colorFor(null) as never);
78
+ });
79
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Standing in for a portrait nobody uploaded.
3
+ *
4
+ * Volto draws a camera icon when a user has no portrait, which is the same
5
+ * picture for everybody: it says "no image here" rather than "this is you".
6
+ * Initials on a colour derived from the userid are recognisable at a glance
7
+ * and stay put -- the same person gets the same colour on every visit, on
8
+ * every device, without anything being stored.
9
+ * @module helpers/avatar
10
+ */
11
+
12
+ /**
13
+ * The palette initials are drawn on.
14
+ *
15
+ * Chosen for contrast against white text rather than for variety: every one
16
+ * of these clears WCAG AA at the size the avatar renders. Ten is enough that
17
+ * two people in a room rarely collide and few enough that each stays
18
+ * distinct -- a larger palette mostly adds colours that look alike.
19
+ */
20
+ export const AVATAR_COLORS = [
21
+ '#0083be',
22
+ '#005d7a',
23
+ '#8b2f8b',
24
+ '#a13d63',
25
+ '#b5451b',
26
+ '#7a5c00',
27
+ '#2d6a4f',
28
+ '#1b5e8b',
29
+ '#5f3dc4',
30
+ '#8a3324',
31
+ ] as const;
32
+
33
+ /**
34
+ * Return the initials to draw for a user.
35
+ *
36
+ * @param name The user's full name, or their login when they have no name.
37
+ * @returns One or two uppercase letters, or an empty string when the name
38
+ * carries no letter at all.
39
+ */
40
+ export function initialsFor(name: string | undefined | null): string {
41
+ const words = (name ?? '')
42
+ .trim()
43
+ .split(/\s+/)
44
+ .filter((word) => /\p{L}/u.test(word));
45
+ if (words.length === 0) {
46
+ return '';
47
+ }
48
+ // First and last rather than the first two: "Érico de Andrei" reads as ÉA,
49
+ // and a middle name should not push the surname out.
50
+ const letters =
51
+ words.length === 1
52
+ ? [...words[0]].filter((c) => /\p{L}/u.test(c)).slice(0, 2)
53
+ : [firstLetter(words[0]), firstLetter(words[words.length - 1])];
54
+ return letters.join('').toLocaleUpperCase();
55
+ }
56
+
57
+ /**
58
+ * Return the first letter of a word, skipping anything that is not one.
59
+ *
60
+ * @param word The word.
61
+ * @returns The letter, or an empty string.
62
+ */
63
+ function firstLetter(word: string): string {
64
+ return [...word].find((c) => /\p{L}/u.test(c)) ?? '';
65
+ }
66
+
67
+ /**
68
+ * Return the colour a user's initials are drawn on.
69
+ *
70
+ * Derived from the userid rather than the name, so somebody correcting the
71
+ * spelling of their own name does not change colour.
72
+ *
73
+ * @param userid The canonical Plone userid.
74
+ * @returns One of :data:`AVATAR_COLORS`.
75
+ */
76
+ export function colorFor(userid: string | undefined | null): string {
77
+ const seed = userid ?? '';
78
+ let hash = 0;
79
+ for (const char of seed) {
80
+ // Ordinary string hash. It needs to be stable and spread, not secure --
81
+ // this picks a colour, and knowing how it picks reveals nothing.
82
+ hash = (hash * 31 + char.codePointAt(0)!) % 0xffffffff;
83
+ }
84
+ return AVATAR_COLORS[hash % AVATAR_COLORS.length];
85
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { readCallback } from './callback';
4
+
5
+ describe('readCallback', () => {
6
+ it('reads an authorization code', () => {
7
+ const parsed = readCallback('?code=abc&state=xyz&provider=dex');
8
+
9
+ expect(parsed.kind).toBe('code');
10
+ expect(parsed.code).toBe('abc');
11
+ expect(parsed.state).toBe('xyz');
12
+ expect(parsed.provider).toBe('dex');
13
+ });
14
+
15
+ it('reads a magic link', () => {
16
+ const parsed = readCallback('?magic_link=tok');
17
+
18
+ expect(parsed.kind).toBe('magic-link');
19
+ expect(parsed.token).toBe('tok');
20
+ });
21
+
22
+ it('reports a provider refusal', () => {
23
+ // Providers signal a refusal by redirecting back with ?error=, not by
24
+ // failing the redirect. Missing this leaves the page spinning forever.
25
+ const parsed = readCallback('?error=access_denied&state=xyz');
26
+
27
+ expect(parsed.kind).toBe('error');
28
+ expect(parsed.error).toBe('access_denied');
29
+ });
30
+
31
+ it('prefers the refusal over a code that came with it', () => {
32
+ const parsed = readCallback('?error=access_denied&code=abc&state=xyz');
33
+
34
+ expect(parsed.kind).toBe('error');
35
+ });
36
+
37
+ it('treats a code without a state as incomplete', () => {
38
+ expect(readCallback('?code=abc').kind).toBe('none');
39
+ });
40
+
41
+ it('treats a state without a code as incomplete', () => {
42
+ expect(readCallback('?state=xyz').kind).toBe('none');
43
+ });
44
+
45
+ it('treats an empty query as incomplete', () => {
46
+ expect(readCallback('').kind).toBe('none');
47
+ });
48
+
49
+ it('ignores repeated parameters rather than guessing', () => {
50
+ // qs.parse gives an array for a repeated key. Taking the first would be
51
+ // picking one of two credentials an attacker supplied.
52
+ expect(readCallback('?magic_link=one&magic_link=two').kind).toBe('none');
53
+ });
54
+
55
+ it('defaults the provider to empty rather than undefined', () => {
56
+ const parsed = readCallback('?code=abc&state=xyz');
57
+
58
+ expect(parsed.provider).toBe('');
59
+ });
60
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Reading the credential off a callback URL.
3
+ *
4
+ * Kept out of the component so it can be tested as what it is: a pure
5
+ * function over a query string, and the place every refusal is decided.
6
+ *
7
+ * Uses ``URLSearchParams`` rather than a query-string library, which brings
8
+ * the repeated-parameter handling with it: ``getAll`` makes ``?code=a&code=b``
9
+ * visibly two values, and picking one of two credentials an attacker supplied
10
+ * is not a decision this should be making quietly.
11
+ * @module helpers/callback
12
+ */
13
+
14
+ export type CallbackKind = 'code' | 'magic-link' | 'error' | 'none';
15
+
16
+ export interface ParsedCallback {
17
+ kind: CallbackKind;
18
+ provider?: string;
19
+ code?: string;
20
+ state?: string;
21
+ token?: string;
22
+ error?: string;
23
+ }
24
+
25
+ /**
26
+ * Return a parameter only when it appears exactly once.
27
+ *
28
+ * @param params The parsed query string.
29
+ * @param name Parameter to read.
30
+ * @returns The value, or ``undefined`` when absent or repeated.
31
+ */
32
+ function single(params: URLSearchParams, name: string): string | undefined {
33
+ const values = params.getAll(name);
34
+ return values.length === 1 ? values[0] : undefined;
35
+ }
36
+
37
+ /**
38
+ * Read the credential off a callback URL.
39
+ *
40
+ * @param search The query string, with or without its leading ``?``.
41
+ * @returns What kind of callback this is, and its parts.
42
+ */
43
+ export function readCallback(search: string): ParsedCallback {
44
+ const params = new URLSearchParams(search);
45
+
46
+ const error = single(params, 'error');
47
+ if (error) {
48
+ // Providers report a refusal by redirecting back with ?error=, not by
49
+ // failing the redirect. Without this the page would sit on "signing you
50
+ // in" forever.
51
+ return { kind: 'error', error };
52
+ }
53
+
54
+ const token = single(params, 'magic_link');
55
+ if (token) {
56
+ return { kind: 'magic-link', token };
57
+ }
58
+
59
+ const code = single(params, 'code');
60
+ const state = single(params, 'state');
61
+ if (code && state) {
62
+ return {
63
+ kind: 'code',
64
+ code,
65
+ state,
66
+ provider: single(params, 'provider') ?? '',
67
+ };
68
+ }
69
+
70
+ return { kind: 'none' };
71
+ }