@plone-collective/volto-identity 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changelog.draft +261 -0
- package/.release-it.json +34 -0
- package/CHANGELOG.md +269 -0
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/babel.config.js +17 -0
- package/locales/de/LC_MESSAGES/volto.po +1162 -0
- package/locales/en/LC_MESSAGES/volto.po +1162 -0
- package/locales/es/LC_MESSAGES/volto.po +1169 -0
- package/locales/pt_BR/LC_MESSAGES/volto.po +1167 -0
- package/locales/volto.pot +1164 -0
- package/news/.changelog_template.jinja +15 -0
- package/news/.gitkeep +0 -0
- package/package.json +66 -0
- package/public/.gitkeep +0 -0
- package/src/actions/actions.test.ts +226 -0
- package/src/actions/index.ts +530 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/Applications/Applications.tsx +169 -0
- package/src/components/Applications/ApplicationsPanel.scss +59 -0
- package/src/components/Applications/ApplicationsPanel.stories.tsx +82 -0
- package/src/components/Applications/ApplicationsPanel.test.tsx +235 -0
- package/src/components/Applications/ApplicationsPanel.tsx +342 -0
- package/src/components/Callback/Callback.scss +17 -0
- package/src/components/Callback/Callback.stories.tsx +43 -0
- package/src/components/Callback/Callback.test.tsx +262 -0
- package/src/components/Callback/Callback.tsx +170 -0
- package/src/components/Consent/Consent.test.tsx +75 -0
- package/src/components/Consent/Consent.tsx +98 -0
- package/src/components/Consent/ConsentPanel.scss +37 -0
- package/src/components/Consent/ConsentPanel.stories.tsx +80 -0
- package/src/components/Consent/ConsentPanel.test.tsx +138 -0
- package/src/components/Consent/ConsentPanel.tsx +162 -0
- package/src/components/ControlPanel/ClientsControlPanel.stories.tsx +38 -0
- package/src/components/ControlPanel/ClientsControlPanel.tsx +321 -0
- package/src/components/ControlPanel/ClientsPanel.scss +21 -0
- package/src/components/ControlPanel/ClientsPanel.stories.tsx +71 -0
- package/src/components/ControlPanel/ClientsPanel.test.tsx +267 -0
- package/src/components/ControlPanel/ClientsPanel.tsx +386 -0
- package/src/components/ControlPanel/ConfirmModal.stories.tsx +35 -0
- package/src/components/ControlPanel/ConfirmModal.test.tsx +66 -0
- package/src/components/ControlPanel/ConfirmModal.tsx +68 -0
- package/src/components/ControlPanel/ProviderForm.stories.tsx +107 -0
- package/src/components/ControlPanel/ProvidersControlPanel.scss +26 -0
- package/src/components/ControlPanel/ProvidersControlPanel.stories.tsx +88 -0
- package/src/components/ControlPanel/ProvidersControlPanel.tsx +600 -0
- package/src/components/ControlPanel/RenderUsers.stories.tsx +112 -0
- package/src/components/ControlPanel/RenderUsers.test.tsx +145 -0
- package/src/components/ControlPanel/RenderUsers.tsx +263 -0
- package/src/components/ControlPanel/SecretReveal.scss +32 -0
- package/src/components/ControlPanel/SecretReveal.stories.tsx +33 -0
- package/src/components/ControlPanel/SecretReveal.test.tsx +101 -0
- package/src/components/ControlPanel/SecretReveal.tsx +105 -0
- package/src/components/ControlPanel/UserAccount.stories.tsx +69 -0
- package/src/components/ControlPanel/UserAccount.test.tsx +117 -0
- package/src/components/ControlPanel/UserAccount.tsx +125 -0
- package/src/components/ControlPanel/UserAccountPanel.scss +38 -0
- package/src/components/ControlPanel/UserAccountPanel.stories.tsx +77 -0
- package/src/components/ControlPanel/UserAccountPanel.test.tsx +173 -0
- package/src/components/ControlPanel/UserAccountPanel.tsx +273 -0
- package/src/components/FirstLogin/FirstLogin.scss +12 -0
- package/src/components/FirstLogin/FirstLogin.stories.tsx +50 -0
- package/src/components/FirstLogin/FirstLogin.tsx +94 -0
- package/src/components/Identities/Identities.stories.tsx +51 -0
- package/src/components/Identities/Identities.test.tsx +134 -0
- package/src/components/Identities/Identities.tsx +217 -0
- package/src/components/Identities/IdentitiesList.scss +49 -0
- package/src/components/Identities/IdentitiesList.stories.tsx +71 -0
- package/src/components/Identities/IdentitiesList.test.tsx +218 -0
- package/src/components/Identities/IdentitiesList.tsx +207 -0
- package/src/components/Identities/ProfileEmails.scss +62 -0
- package/src/components/Identities/ProfileEmails.stories.tsx +75 -0
- package/src/components/Identities/ProfileEmails.test.tsx +132 -0
- package/src/components/Identities/ProfileEmails.tsx +217 -0
- package/src/components/Login/Login.stories.tsx +63 -0
- package/src/components/Login/Login.test.tsx +189 -0
- package/src/components/Login/Login.tsx +163 -0
- package/src/components/Login/LoginForm.scss +39 -0
- package/src/components/Login/LoginForm.stories.tsx +112 -0
- package/src/components/Login/LoginForm.test.tsx +483 -0
- package/src/components/Login/LoginForm.tsx +321 -0
- package/src/components/Login/LoginOverlay.scss +44 -0
- package/src/components/Login/LoginOverlay.stories.tsx +73 -0
- package/src/components/Login/LoginOverlay.test.tsx +98 -0
- package/src/components/Login/LoginOverlay.tsx +107 -0
- package/src/components/Login/LoginPanel.scss +50 -0
- package/src/components/Login/LoginPanel.stories.tsx +40 -0
- package/src/components/Login/LoginPanel.tsx +60 -0
- package/src/components/Login/MagicLinkForm.scss +25 -0
- package/src/components/Login/MagicLinkForm.stories.tsx +27 -0
- package/src/components/Login/MagicLinkForm.tsx +133 -0
- package/src/components/Login/PasswordForm.scss +82 -0
- package/src/components/Login/PasswordForm.stories.tsx +26 -0
- package/src/components/Login/PasswordForm.tsx +159 -0
- package/src/components/Login/ProviderButton.scss +103 -0
- package/src/components/Login/ProviderButton.stories.tsx +74 -0
- package/src/components/Login/ProviderButton.test.tsx +99 -0
- package/src/components/Login/ProviderButton.tsx +97 -0
- package/src/components/ProfileGate/ProfileGate.stories.tsx +118 -0
- package/src/components/ProfileGate/ProfileGate.test.tsx +370 -0
- package/src/components/ProfileGate/ProfileGate.tsx +172 -0
- package/src/components/Toolbar/PersonalTools.stories.tsx +111 -0
- package/src/components/Toolbar/PersonalTools.test.tsx +165 -0
- package/src/components/Toolbar/PersonalTools.tsx +177 -0
- package/src/components/Toolbar/Toolbar.jsx +766 -0
- package/src/components/UserMenu/ApplicationsMenuItem.stories.tsx +62 -0
- package/src/components/UserMenu/ApplicationsMenuItem.test.tsx +109 -0
- package/src/components/UserMenu/ApplicationsMenuItem.tsx +82 -0
- package/src/components/UserMenu/IdentitiesMenuItem.stories.tsx +35 -0
- package/src/components/UserMenu/IdentitiesMenuItem.test.tsx +103 -0
- package/src/components/UserMenu/IdentitiesMenuItem.tsx +34 -0
- package/src/components/UserMenu/MenuItem.stories.tsx +32 -0
- package/src/components/UserMenu/MenuItem.tsx +48 -0
- package/src/components/UserMenu/ProfileMenuItem.stories.tsx +76 -0
- package/src/components/UserMenu/ProfileMenuItem.test.tsx +90 -0
- package/src/components/UserMenu/ProfileMenuItem.tsx +60 -0
- package/src/components/UserMenu/UserAvatar.scss +23 -0
- package/src/components/UserMenu/UserAvatar.stories.tsx +121 -0
- package/src/components/UserMenu/UserAvatar.test.tsx +120 -0
- package/src/components/UserMenu/UserAvatar.tsx +119 -0
- package/src/components/UserMenu/UserMenuPlugs.stories.tsx +101 -0
- package/src/components/UserMenu/UserMenuPlugs.test.tsx +202 -0
- package/src/components/UserMenu/UserMenuPlugs.tsx +142 -0
- package/src/components/UserMenu/UserProfileLoader.test.tsx +211 -0
- package/src/components/UserMenu/UserProfileLoader.tsx +54 -0
- package/src/components/Views/GroupView.scss +36 -0
- package/src/components/Views/GroupView.stories.tsx +98 -0
- package/src/components/Views/GroupView.test.tsx +137 -0
- package/src/components/Views/GroupView.tsx +178 -0
- package/src/components/Views/ProfileView.scss +27 -0
- package/src/components/Views/ProfileView.stories.tsx +34 -0
- package/src/components/Views/ProfileView.test.tsx +82 -0
- package/src/components/Views/ProfileView.tsx +100 -0
- package/src/components/Widgets/ProviderIconWidget.scss +21 -0
- package/src/components/Widgets/ProviderIconWidget.stories.tsx +30 -0
- package/src/components/Widgets/ProviderIconWidget.test.tsx +55 -0
- package/src/components/Widgets/ProviderIconWidget.tsx +143 -0
- package/src/config/menu.ts +35 -0
- package/src/config/reducers.ts +7 -0
- package/src/config/routes.test.ts +177 -0
- package/src/config/routes.ts +143 -0
- package/src/config/settings.test.ts +34 -0
- package/src/config/settings.ts +72 -0
- package/src/config/views.ts +30 -0
- package/src/config/widgets.ts +25 -0
- package/src/constants/ActionTypes.ts +42 -0
- package/src/constants/vocabularies.ts +20 -0
- package/src/customizations/volto/components/manage/Controlpanels/Users/RenderUsers.tsx +23 -0
- package/src/customizations/volto/components/manage/Toolbar/PersonalTools.tsx +21 -0
- package/src/customizations/volto/components/manage/Toolbar/Toolbar.jsx +21 -0
- package/src/helpers/avatar.test.ts +79 -0
- package/src/helpers/avatar.ts +85 -0
- package/src/helpers/callback.test.ts +60 -0
- package/src/helpers/callback.ts +71 -0
- package/src/helpers/clientSchema.test.ts +182 -0
- package/src/helpers/clientSchema.ts +181 -0
- package/src/helpers/firstLogin.test.ts +90 -0
- package/src/helpers/firstLogin.ts +76 -0
- package/src/helpers/groupmap.test.ts +81 -0
- package/src/helpers/groupmap.ts +50 -0
- package/src/helpers/identities.ts +22 -0
- package/src/helpers/navigate.test.ts +132 -0
- package/src/helpers/navigate.ts +126 -0
- package/src/helpers/profileGate.test.ts +150 -0
- package/src/helpers/profileGate.ts +191 -0
- package/src/helpers/profileSource.ts +41 -0
- package/src/helpers/propertymap.test.ts +71 -0
- package/src/helpers/propertymap.ts +43 -0
- package/src/helpers/providerSchema.test.ts +410 -0
- package/src/helpers/providerSchema.ts +362 -0
- package/src/helpers/returnUrl.test.ts +42 -0
- package/src/helpers/returnUrl.ts +41 -0
- package/src/helpers/rowmap.ts +110 -0
- package/src/helpers/showPloneLogin.test.ts +78 -0
- package/src/helpers/showPloneLogin.ts +55 -0
- package/src/helpers/token.test.ts +62 -0
- package/src/helpers/token.ts +71 -0
- package/src/index.ts +22 -0
- package/src/reducers/index.ts +369 -0
- package/src/reducers/reducers.test.ts +369 -0
- package/src/stories/fixtures.tsx +640 -0
- package/src/storybook/withPage.test.tsx +90 -0
- package/src/storybook/withPage.tsx +110 -0
- package/src/storybook/withUserMenu.test.tsx +78 -0
- package/src/storybook/withUserMenu.tsx +136 -0
- package/src/styles.css +262 -0
- package/src/testing.tsx +86 -0
- package/src/types.ts +441 -0
- package/towncrier.toml +38 -0
- package/tsconfig.json +22 -0
- package/vitest.config.mjs +47 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import GroupView from './GroupView';
|
|
4
|
+
import { LOADED, LOADING, withStore } from '../../stories/fixtures';
|
|
5
|
+
|
|
6
|
+
const CONTENT = {
|
|
7
|
+
'@id': '/identity-profiles/staff',
|
|
8
|
+
id: 'staff',
|
|
9
|
+
title: 'Staff',
|
|
10
|
+
description: 'Everybody who works here.',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const MEMBERS = {
|
|
14
|
+
'@id': '/@group-members/staff',
|
|
15
|
+
group: 'staff',
|
|
16
|
+
items_total: 2,
|
|
17
|
+
items: [
|
|
18
|
+
{
|
|
19
|
+
'@id': '/@group-members/staff/alice',
|
|
20
|
+
id: 'alice',
|
|
21
|
+
fullname: 'Alice Liddell',
|
|
22
|
+
login: 'alice@example.com',
|
|
23
|
+
profile_url: '/identity-profiles/alice',
|
|
24
|
+
through: ['developers'],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
'@id': '/@group-members/staff/bob',
|
|
28
|
+
id: 'bob',
|
|
29
|
+
fullname: 'Bob Cratchit',
|
|
30
|
+
login: 'bob@example.com',
|
|
31
|
+
profile_url: '/identity-profiles/bob',
|
|
32
|
+
through: ['staff'],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
nested_groups: [
|
|
36
|
+
{ '@id': '/g/developers', id: 'developers', title: 'Developers' },
|
|
37
|
+
],
|
|
38
|
+
parent_groups: [{ '@id': '/g/everyone', id: 'everyone', title: 'Everyone' }],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const meta: Meta<typeof GroupView> = {
|
|
42
|
+
title: 'Identity/Views/GroupView',
|
|
43
|
+
component: GroupView,
|
|
44
|
+
args: { content: CONTENT },
|
|
45
|
+
};
|
|
46
|
+
export default meta;
|
|
47
|
+
|
|
48
|
+
type Story = StoryObj<typeof GroupView>;
|
|
49
|
+
|
|
50
|
+
/** A group in the middle of a nesting: something above it and below it. */
|
|
51
|
+
export const Nested: Story = {
|
|
52
|
+
decorators: [withStore({ groupMembers: { ...LOADED, data: MEMBERS } })],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A group with nothing nested inside it, which is most of them. */
|
|
56
|
+
export const Flat: Story = {
|
|
57
|
+
decorators: [
|
|
58
|
+
withStore({
|
|
59
|
+
groupMembers: {
|
|
60
|
+
...LOADED,
|
|
61
|
+
data: { ...MEMBERS, nested_groups: [], parent_groups: [] },
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const Empty: Story = {
|
|
68
|
+
decorators: [
|
|
69
|
+
withStore({
|
|
70
|
+
groupMembers: {
|
|
71
|
+
...LOADED,
|
|
72
|
+
data: {
|
|
73
|
+
...MEMBERS,
|
|
74
|
+
items: [],
|
|
75
|
+
nested_groups: [],
|
|
76
|
+
parent_groups: [],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const Loading: Story = {
|
|
84
|
+
decorators: [withStore({ groupMembers: LOADING })],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A visitor who can see the group without being in it.
|
|
89
|
+
*
|
|
90
|
+
* A membership list is personal data about other people, so it is visible to
|
|
91
|
+
* its own members and to somebody who manages users. The page is still a
|
|
92
|
+
* page.
|
|
93
|
+
*/
|
|
94
|
+
export const MembershipRefused: Story = {
|
|
95
|
+
decorators: [
|
|
96
|
+
withStore({ groupMembers: { loaded: false, error: { status: 403 } } }),
|
|
97
|
+
],
|
|
98
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { render, screen } from '../../testing';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import GroupView from './GroupView';
|
|
7
|
+
|
|
8
|
+
const CONTENT = {
|
|
9
|
+
'@id': '/identity-profiles/staff',
|
|
10
|
+
id: 'staff',
|
|
11
|
+
title: 'Staff',
|
|
12
|
+
description: 'Everybody who works here.',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const MEMBERS = {
|
|
16
|
+
'@id': '/@group-members/staff',
|
|
17
|
+
group: 'staff',
|
|
18
|
+
items_total: 2,
|
|
19
|
+
items: [
|
|
20
|
+
{
|
|
21
|
+
'@id': '/@group-members/staff/alice',
|
|
22
|
+
id: 'alice',
|
|
23
|
+
fullname: 'Alice Liddell',
|
|
24
|
+
login: 'alice@example.com',
|
|
25
|
+
profile_url: '/identity-profiles/alice',
|
|
26
|
+
through: ['developers'],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
'@id': '/@group-members/staff/bob',
|
|
30
|
+
id: 'bob',
|
|
31
|
+
fullname: 'Bob Cratchit',
|
|
32
|
+
login: 'bob@example.com',
|
|
33
|
+
profile_url: null,
|
|
34
|
+
through: ['staff'],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
nested_groups: [{ '@id': '/x', id: 'developers', title: 'Developers' }],
|
|
38
|
+
parent_groups: [{ '@id': '/y', id: 'everyone', title: 'Everyone' }],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function renderView(state: any = {}) {
|
|
42
|
+
const store = {
|
|
43
|
+
getState: () => ({ groupMembers: state }),
|
|
44
|
+
dispatch: (action: any) => action,
|
|
45
|
+
subscribe: () => () => {},
|
|
46
|
+
};
|
|
47
|
+
render(
|
|
48
|
+
<Provider store={store as any}>
|
|
49
|
+
<GroupView content={CONTENT} />
|
|
50
|
+
</Provider>,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('GroupView', () => {
|
|
55
|
+
it('shows the title and description', () => {
|
|
56
|
+
renderView({ loaded: true, data: MEMBERS });
|
|
57
|
+
|
|
58
|
+
expect(screen.getByText('Staff')).toBeTruthy();
|
|
59
|
+
expect(screen.getByText('Everybody who works here.')).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('lists the groups nested inside it', () => {
|
|
63
|
+
// The requirement: a group page says what is in it, and membership of an
|
|
64
|
+
// inner group is membership of this one.
|
|
65
|
+
renderView({ loaded: true, data: MEMBERS });
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText('Developers')).toBeTruthy();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('says what it is nested inside', () => {
|
|
71
|
+
renderView({ loaded: true, data: MEMBERS });
|
|
72
|
+
|
|
73
|
+
expect(screen.getByText('Everyone')).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('lists the members', () => {
|
|
77
|
+
renderView({ loaded: true, data: MEMBERS });
|
|
78
|
+
|
|
79
|
+
expect(screen.getByText('Alice Liddell')).toBeTruthy();
|
|
80
|
+
expect(screen.getByText('Bob Cratchit')).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('says which group an inherited member came through', () => {
|
|
84
|
+
// A list that silently mixes direct members with inherited ones is a list
|
|
85
|
+
// nobody can account for.
|
|
86
|
+
renderView({ loaded: true, data: MEMBERS });
|
|
87
|
+
|
|
88
|
+
expect(screen.getByText(/through developers/)).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('says nothing extra about a direct member', () => {
|
|
92
|
+
renderView({ loaded: true, data: MEMBERS });
|
|
93
|
+
|
|
94
|
+
expect(screen.queryByText(/through staff/)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('links a member who has a profile', () => {
|
|
98
|
+
renderView({ loaded: true, data: MEMBERS });
|
|
99
|
+
|
|
100
|
+
const link = screen.getByText('Alice Liddell') as HTMLAnchorElement;
|
|
101
|
+
expect(link.tagName).toBe('A');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not link one who has none', () => {
|
|
105
|
+
// An account that predates the add-on, or a site not keeping users as
|
|
106
|
+
// content: there is nowhere to send the reader.
|
|
107
|
+
renderView({ loaded: true, data: MEMBERS });
|
|
108
|
+
|
|
109
|
+
expect((screen.getByText('Bob Cratchit') as HTMLElement).tagName).toBe(
|
|
110
|
+
'SPAN',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('says so while the membership is loading', () => {
|
|
115
|
+
renderView({ loading: true });
|
|
116
|
+
|
|
117
|
+
expect(screen.getByRole('status')).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('still renders the group when the membership is refused', () => {
|
|
121
|
+
// A visitor who can see the group without being in it gets the title and
|
|
122
|
+
// description. That is a page, not an error.
|
|
123
|
+
renderView({ error: { status: 403 } });
|
|
124
|
+
|
|
125
|
+
expect(screen.getByText('Staff')).toBeTruthy();
|
|
126
|
+
expect(screen.getByText(/visible to its own members/)).toBeTruthy();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('says so when nobody is in it', () => {
|
|
130
|
+
renderView({
|
|
131
|
+
loaded: true,
|
|
132
|
+
data: { ...MEMBERS, items: [], nested_groups: [], parent_groups: [] },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText(/Nobody is in this group/)).toBeTruthy();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a group looks like when somebody opens it.
|
|
3
|
+
*
|
|
4
|
+
* A group is content, so it rendered through Volto's default view: a title
|
|
5
|
+
* and an empty body. What a group page has to say is who is in it and what it
|
|
6
|
+
* contains, and neither is on the content object -- membership is stored on
|
|
7
|
+
* each member, and the nesting is a graph the backend closes over.
|
|
8
|
+
*
|
|
9
|
+
* So the page is the content object for its title and description, and
|
|
10
|
+
* `@group-members/<id>` for everything else. That endpoint answers in one
|
|
11
|
+
* request: the members with enough per person to draw a row, the groups
|
|
12
|
+
* nested inside this one, and the groups this one is nested inside.
|
|
13
|
+
*
|
|
14
|
+
* It may refuse. Reading a group's membership needs `Manage users` or
|
|
15
|
+
* membership of the group itself -- a member list is personal data about
|
|
16
|
+
* other people -- and a visitor who can see the group without being in it
|
|
17
|
+
* gets the title and description and nothing more. That is a page rather than
|
|
18
|
+
* an error, which is why the refusal is rendered as a note instead of being
|
|
19
|
+
* left to the error boundary.
|
|
20
|
+
* @module components/Views/GroupView
|
|
21
|
+
*/
|
|
22
|
+
import React, { useEffect } from 'react';
|
|
23
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
24
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
25
|
+
import { Container } from 'semantic-ui-react';
|
|
26
|
+
import { Link } from 'react-router-dom';
|
|
27
|
+
|
|
28
|
+
import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
|
|
29
|
+
import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
|
|
30
|
+
|
|
31
|
+
import { listGroupMembers } from '../../actions';
|
|
32
|
+
import type { GroupMember, NestedGroup } from '../../types';
|
|
33
|
+
|
|
34
|
+
import './GroupView.scss';
|
|
35
|
+
|
|
36
|
+
const messages = defineMessages({
|
|
37
|
+
nested: {
|
|
38
|
+
id: 'Groups in this group',
|
|
39
|
+
defaultMessage: 'Groups in this group',
|
|
40
|
+
},
|
|
41
|
+
nestedHelp: {
|
|
42
|
+
id: 'group-view-nested-help',
|
|
43
|
+
defaultMessage:
|
|
44
|
+
'Everybody in these groups is in this one as well, at any depth.',
|
|
45
|
+
},
|
|
46
|
+
partOf: { id: 'Part of', defaultMessage: 'Part of' },
|
|
47
|
+
members: { id: 'Members', defaultMessage: 'Members' },
|
|
48
|
+
through: { id: 'group-view-through', defaultMessage: 'through {groups}' },
|
|
49
|
+
noMembers: {
|
|
50
|
+
id: 'group-view-no-members',
|
|
51
|
+
defaultMessage: 'Nobody is in this group yet.',
|
|
52
|
+
},
|
|
53
|
+
refused: {
|
|
54
|
+
id: 'group-view-refused',
|
|
55
|
+
defaultMessage:
|
|
56
|
+
'You can see this group but not who is in it. A membership list is ' +
|
|
57
|
+
'visible to its own members and to somebody who manages users.',
|
|
58
|
+
},
|
|
59
|
+
loading: { id: 'group-view-loading', defaultMessage: 'Loading members…' },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The fields this view reads off a serialized group. */
|
|
63
|
+
interface GroupContent {
|
|
64
|
+
'@id': string;
|
|
65
|
+
id: string;
|
|
66
|
+
title?: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface GroupViewProps {
|
|
71
|
+
content: GroupContent;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render one entry in a list of groups.
|
|
76
|
+
*
|
|
77
|
+
* @param group The group to link to.
|
|
78
|
+
* @returns The list item.
|
|
79
|
+
*/
|
|
80
|
+
function groupItem(group: NestedGroup) {
|
|
81
|
+
return (
|
|
82
|
+
<li key={group.id} data-group={group.id}>
|
|
83
|
+
{group.title || group.id}
|
|
84
|
+
</li>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const GroupView: React.FC<GroupViewProps> = ({ content }) => {
|
|
89
|
+
const intl = useIntl();
|
|
90
|
+
const dispatch = useDispatch();
|
|
91
|
+
const state = useSelector((store: any) => store.groupMembers);
|
|
92
|
+
const groupId = content.id;
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (groupId) {
|
|
96
|
+
dispatch(listGroupMembers(groupId));
|
|
97
|
+
}
|
|
98
|
+
}, [dispatch, groupId]);
|
|
99
|
+
|
|
100
|
+
const data = state?.data;
|
|
101
|
+
// The membership only, not the group itself: a group that is loading and a
|
|
102
|
+
// group somebody may not read are two different pages, and only one of them
|
|
103
|
+
// has anything to wait for.
|
|
104
|
+
const refused = Boolean(state?.error);
|
|
105
|
+
const members: GroupMember[] = data?.items ?? [];
|
|
106
|
+
const nested: NestedGroup[] = data?.nested_groups ?? [];
|
|
107
|
+
const parents: NestedGroup[] = data?.parent_groups ?? [];
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Container className="view-wrapper identity-group-view">
|
|
111
|
+
<Helmet title={content.title || content.id} />
|
|
112
|
+
<h1 className="documentFirstHeading">{content.title || content.id}</h1>
|
|
113
|
+
{content.description ? (
|
|
114
|
+
<p className="documentDescription">{content.description}</p>
|
|
115
|
+
) : null}
|
|
116
|
+
|
|
117
|
+
{parents.length ? (
|
|
118
|
+
<section className="identity-group-view__parents">
|
|
119
|
+
<h2>{intl.formatMessage(messages.partOf)}</h2>
|
|
120
|
+
<ul>{parents.map(groupItem)}</ul>
|
|
121
|
+
</section>
|
|
122
|
+
) : null}
|
|
123
|
+
|
|
124
|
+
{nested.length ? (
|
|
125
|
+
<section className="identity-group-view__nested">
|
|
126
|
+
<h2>{intl.formatMessage(messages.nested)}</h2>
|
|
127
|
+
<p className="identity-note">
|
|
128
|
+
{intl.formatMessage(messages.nestedHelp)}
|
|
129
|
+
</p>
|
|
130
|
+
<ul>{nested.map(groupItem)}</ul>
|
|
131
|
+
</section>
|
|
132
|
+
) : null}
|
|
133
|
+
|
|
134
|
+
<section className="identity-group-view__members">
|
|
135
|
+
<h2>{intl.formatMessage(messages.members)}</h2>
|
|
136
|
+
{refused ? (
|
|
137
|
+
<p className="identity-note">
|
|
138
|
+
{intl.formatMessage(messages.refused)}
|
|
139
|
+
</p>
|
|
140
|
+
) : state?.loading ? (
|
|
141
|
+
<p className="identity-note" role="status">
|
|
142
|
+
{intl.formatMessage(messages.loading)}
|
|
143
|
+
</p>
|
|
144
|
+
) : members.length ? (
|
|
145
|
+
<ul className="identity-group-view__list">
|
|
146
|
+
{members.map((member) => (
|
|
147
|
+
<li key={member.id} data-userid={member.id}>
|
|
148
|
+
{member.profile_url ? (
|
|
149
|
+
<Link to={flattenToAppURL(member.profile_url)}>
|
|
150
|
+
{member.fullname}
|
|
151
|
+
</Link>
|
|
152
|
+
) : (
|
|
153
|
+
<span>{member.fullname}</span>
|
|
154
|
+
)}
|
|
155
|
+
{/* Which group this person arrived through, when it was not
|
|
156
|
+
this one. A list that silently mixes direct members with
|
|
157
|
+
inherited ones is a list nobody can account for. */}
|
|
158
|
+
{member.through.includes(groupId) ? null : (
|
|
159
|
+
<span className="identity-note">
|
|
160
|
+
{intl.formatMessage(messages.through, {
|
|
161
|
+
groups: member.through.join(', '),
|
|
162
|
+
})}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</li>
|
|
166
|
+
))}
|
|
167
|
+
</ul>
|
|
168
|
+
) : (
|
|
169
|
+
<p className="identity-note">
|
|
170
|
+
{intl.formatMessage(messages.noMembers)}
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
</section>
|
|
174
|
+
</Container>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export default GroupView;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user's Profile, seen by somebody else.
|
|
3
|
+
*
|
|
4
|
+
* The picture and the name sit on one line rather than stacked: a profile
|
|
5
|
+
* page is a person, and a portrait above a heading reads as an article with
|
|
6
|
+
* a lead image.
|
|
7
|
+
*/
|
|
8
|
+
.identity-profile-view__header {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
margin-bottom: var(--identity-gap);
|
|
12
|
+
gap: 16px;
|
|
13
|
+
|
|
14
|
+
h1 {
|
|
15
|
+
margin: 0;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.identity-profile-view__picture {
|
|
20
|
+
width: 96px;
|
|
21
|
+
height: 96px;
|
|
22
|
+
flex: none;
|
|
23
|
+
border-radius: 50%;
|
|
24
|
+
/* An uploaded portrait is whatever shape it was cropped to; a circle over
|
|
25
|
+
a stretched image is worse than a circle over a cropped one. */
|
|
26
|
+
object-fit: cover;
|
|
27
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import ProfileView from './ProfileView';
|
|
4
|
+
import { PORTRAIT } from '../../stories/fixtures';
|
|
5
|
+
|
|
6
|
+
const CONTENT = {
|
|
7
|
+
'@id': '/identity-profiles/erico',
|
|
8
|
+
title: 'Érico Andrei',
|
|
9
|
+
fullname: 'Érico Andrei',
|
|
10
|
+
description: 'Plone developer, and the person this add-on is written for.',
|
|
11
|
+
image: { download: PORTRAIT, scales: { preview: { download: PORTRAIT } } },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof ProfileView> = {
|
|
15
|
+
title: 'Identity/Views/ProfileView',
|
|
16
|
+
component: ProfileView,
|
|
17
|
+
args: { content: CONTENT },
|
|
18
|
+
};
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
type Story = StoryObj<typeof ProfileView>;
|
|
22
|
+
|
|
23
|
+
/** Everything filled in: a name, a biography and a picture. */
|
|
24
|
+
export const Complete: Story = {};
|
|
25
|
+
|
|
26
|
+
/** No picture. The page is the name and what they said about themselves. */
|
|
27
|
+
export const WithoutAPicture: Story = {
|
|
28
|
+
args: { content: { ...CONTENT, image: null } },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** A profile minted at first login by a provider that sent only a name. */
|
|
32
|
+
export const JustCreated: Story = {
|
|
33
|
+
args: { content: { ...CONTENT, description: '', image: null } },
|
|
34
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { render, screen } from '../../testing';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import ProfileView, { pictureUrl } from './ProfileView';
|
|
6
|
+
|
|
7
|
+
const CONTENT = {
|
|
8
|
+
'@id': '/identity-profiles/alice',
|
|
9
|
+
title: 'Alice Liddell',
|
|
10
|
+
fullname: 'Alice Liddell',
|
|
11
|
+
description: 'Reads a lot.',
|
|
12
|
+
image: {
|
|
13
|
+
download: '/identity-profiles/alice/@@images/image',
|
|
14
|
+
scales: {
|
|
15
|
+
preview: { download: '/identity-profiles/alice/@@images/image/preview' },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('pictureUrl', () => {
|
|
21
|
+
it('prefers a scale over the original', () => {
|
|
22
|
+
// An uploaded portrait is whatever the camera produced, and a page that
|
|
23
|
+
// renders it at full size downloads megabytes to draw a thumbnail.
|
|
24
|
+
expect(pictureUrl(CONTENT.image)).toContain('/preview');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('falls back to the original when there is no scale', () => {
|
|
28
|
+
expect(pictureUrl({ download: '/x' })).toBe('/x');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('answers nothing for a profile with no picture', () => {
|
|
32
|
+
expect(pictureUrl(null)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('ProfileView', () => {
|
|
37
|
+
it('shows the full name as the heading', () => {
|
|
38
|
+
render(<ProfileView content={CONTENT} />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByRole('heading').textContent).toBe('Alice Liddell');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('shows the biography', () => {
|
|
44
|
+
render(<ProfileView content={CONTENT} />);
|
|
45
|
+
|
|
46
|
+
expect(screen.getByText('Reads a lot.')).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('shows the picture, labelled with the name', () => {
|
|
50
|
+
render(<ProfileView content={CONTENT} />);
|
|
51
|
+
|
|
52
|
+
const image = screen.getByAltText('Alice Liddell') as HTMLImageElement;
|
|
53
|
+
expect(image.getAttribute('src')).toContain('/preview');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders no image element when there is no picture', () => {
|
|
57
|
+
render(<ProfileView content={{ ...CONTENT, image: null }} />);
|
|
58
|
+
|
|
59
|
+
expect(document.querySelector('img')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('falls back to the computed title when there is no full name', () => {
|
|
63
|
+
// The backend computes `title` from the full name and then the login, so
|
|
64
|
+
// it is never empty -- which makes it the honest fallback.
|
|
65
|
+
render(<ProfileView content={{ ...CONTENT, fullname: '' }} />);
|
|
66
|
+
|
|
67
|
+
expect(screen.getByRole('heading').textContent).toBe('Alice Liddell');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('never publishes an address', () => {
|
|
71
|
+
// The field carries a read permission of its own, but a view that
|
|
72
|
+
// rendered it when it happened to be there would publish an address on a
|
|
73
|
+
// page whose URL is guessable from a userid.
|
|
74
|
+
render(
|
|
75
|
+
<ProfileView
|
|
76
|
+
content={{ ...CONTENT, email: 'alice@example.com' } as any}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(screen.queryByText(/alice@example.com/)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a user's Profile looks like when somebody opens it.
|
|
3
|
+
*
|
|
4
|
+
* A Profile is content, so without a view of its own it rendered through
|
|
5
|
+
* Volto's default: a title, and a body that is empty because a Profile has no
|
|
6
|
+
* rich text. What a person visiting one wants is the three things that say who
|
|
7
|
+
* this is -- their name, what they say about themselves, and their picture.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately not the address. The `email` field carries a read permission
|
|
10
|
+
* of its own (`...content.viewpii`), so it is absent from the serialization
|
|
11
|
+
* for most callers anyway -- but a view that rendered it when it happened to
|
|
12
|
+
* be there would publish an address on a page whose URL is guessable from a
|
|
13
|
+
* userid. The account's own owner sees their addresses on their sign-in
|
|
14
|
+
* methods page, where verifying one is also possible.
|
|
15
|
+
* @module components/Views/ProfileView
|
|
16
|
+
*/
|
|
17
|
+
import React from 'react';
|
|
18
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
19
|
+
|
|
20
|
+
import { Container } from 'semantic-ui-react';
|
|
21
|
+
|
|
22
|
+
import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
|
|
23
|
+
|
|
24
|
+
import './ProfileView.scss';
|
|
25
|
+
|
|
26
|
+
const messages = defineMessages({
|
|
27
|
+
noName: { id: 'profile-view-unnamed', defaultMessage: 'Unnamed user' },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The shape of the fields this view reads off a serialized Profile. */
|
|
31
|
+
interface ProfileContent {
|
|
32
|
+
'@id': string;
|
|
33
|
+
title?: string;
|
|
34
|
+
fullname?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
image?: {
|
|
37
|
+
download?: string;
|
|
38
|
+
scales?: Record<string, { download?: string }>;
|
|
39
|
+
} | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ProfileViewProps {
|
|
43
|
+
content: ProfileContent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return the best available URL for a Profile's picture.
|
|
48
|
+
*
|
|
49
|
+
* A scale rather than the original when there is one: an uploaded portrait
|
|
50
|
+
* is whatever the camera produced, and a page that renders it at full size
|
|
51
|
+
* downloads several megabytes to draw a thumbnail.
|
|
52
|
+
*
|
|
53
|
+
* @param image The serialized image field.
|
|
54
|
+
* @returns The URL, or null when the Profile has no picture.
|
|
55
|
+
*/
|
|
56
|
+
export function pictureUrl(image: ProfileContent['image']): string | null {
|
|
57
|
+
if (!image) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return (
|
|
61
|
+
image.scales?.preview?.download ??
|
|
62
|
+
image.scales?.mini?.download ??
|
|
63
|
+
image.download ??
|
|
64
|
+
null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ProfileView: React.FC<ProfileViewProps> = ({ content }) => {
|
|
69
|
+
const intl = useIntl();
|
|
70
|
+
// `title` is computed on the backend from the full name, falling back to
|
|
71
|
+
// the login -- so it is never empty, and it is the honest thing to fall
|
|
72
|
+
// back to before preferring a message of our own.
|
|
73
|
+
const name =
|
|
74
|
+
content.fullname || content.title || intl.formatMessage(messages.noName);
|
|
75
|
+
const picture = pictureUrl(content.image);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Container className="view-wrapper identity-profile-view">
|
|
79
|
+
<Helmet title={name} />
|
|
80
|
+
<div className="identity-profile-view__header">
|
|
81
|
+
{picture ? (
|
|
82
|
+
<img
|
|
83
|
+
className="identity-profile-view__picture"
|
|
84
|
+
src={picture}
|
|
85
|
+
// The name, not "portrait of": a screen reader announcing the
|
|
86
|
+
// word twice on a page whose heading is already the name adds
|
|
87
|
+
// nothing.
|
|
88
|
+
alt={name}
|
|
89
|
+
/>
|
|
90
|
+
) : null}
|
|
91
|
+
<h1 className="documentFirstHeading">{name}</h1>
|
|
92
|
+
</div>
|
|
93
|
+
{content.description ? (
|
|
94
|
+
<p className="documentDescription">{content.description}</p>
|
|
95
|
+
) : null}
|
|
96
|
+
</Container>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export default ProfileView;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The icon upload, sized so the preview reads as a button icon rather than as
|
|
3
|
+
* an illustration: it is drawn at button scale on the login page, and a
|
|
4
|
+
* preview at any other size would be a different thing.
|
|
5
|
+
*/
|
|
6
|
+
.provider-icon-widget__preview {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
width: 48px;
|
|
9
|
+
height: 48px;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
padding: 8px;
|
|
13
|
+
border: 1px solid #ddd;
|
|
14
|
+
border-radius: 4px;
|
|
15
|
+
margin-bottom: 8px;
|
|
16
|
+
|
|
17
|
+
svg {
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
}
|
|
21
|
+
}
|