@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,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actions for the identity login flows.
|
|
3
|
+
*
|
|
4
|
+
* Every one of them is a plain Volto API action: the `request` key is picked
|
|
5
|
+
* up by Volto's api middleware, which does the fetch and dispatches the
|
|
6
|
+
* `_PENDING` / `_SUCCESS` / `_FAIL` triple.
|
|
7
|
+
* @module actions
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
COMPLETE_CALLBACK,
|
|
12
|
+
CONFIRM_MAGIC_LINK,
|
|
13
|
+
GET_CONSENT_REQUEST,
|
|
14
|
+
LIST_GRANTS,
|
|
15
|
+
WITHDRAW_GRANT,
|
|
16
|
+
CREATE_CLIENT,
|
|
17
|
+
CREATE_PROVIDER,
|
|
18
|
+
DELETE_CLIENT,
|
|
19
|
+
DELETE_PROVIDER,
|
|
20
|
+
GET_MY_PROFILE,
|
|
21
|
+
GET_USER_ACCOUNT,
|
|
22
|
+
GET_USER_PROFILE,
|
|
23
|
+
LIST_GROUP_MEMBERS,
|
|
24
|
+
LIST_CLIENTS,
|
|
25
|
+
LIST_DRIVERS,
|
|
26
|
+
LIST_KEYS,
|
|
27
|
+
LIST_IDENTITIES,
|
|
28
|
+
LIST_LOGIN_PROVIDERS,
|
|
29
|
+
LIST_PROVIDERS,
|
|
30
|
+
ROTATE_CLIENT_SECRET,
|
|
31
|
+
ROTATE_KEY,
|
|
32
|
+
SEND_MAGIC_LINK,
|
|
33
|
+
SET_PREFERRED_EMAIL,
|
|
34
|
+
START_LINKING,
|
|
35
|
+
START_PROVIDER_LOGIN,
|
|
36
|
+
TEST_PROVIDER,
|
|
37
|
+
UNLINK_IDENTITY,
|
|
38
|
+
UPDATE_CLIENT,
|
|
39
|
+
UPDATE_PROVIDER,
|
|
40
|
+
} from '../constants/ActionTypes';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List the providers this site offers.
|
|
44
|
+
*/
|
|
45
|
+
export function listLoginProviders() {
|
|
46
|
+
return {
|
|
47
|
+
type: LIST_LOGIN_PROVIDERS,
|
|
48
|
+
request: { op: 'get', path: '/@login-providers' },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Start an authorization-code flow against one provider.
|
|
54
|
+
*
|
|
55
|
+
* Separate from the listing on purpose: this mints the state, PKCE verifier
|
|
56
|
+
* and nonce, so calling it for every provider on page load would leave a pile
|
|
57
|
+
* of unused attempts behind.
|
|
58
|
+
*
|
|
59
|
+
* @param providerId Provider to log in with.
|
|
60
|
+
* @param cameFrom Where to send the user afterwards.
|
|
61
|
+
*/
|
|
62
|
+
export function startProviderLogin(providerId: string, cameFrom = '') {
|
|
63
|
+
const query = cameFrom ? `?came_from=${encodeURIComponent(cameFrom)}` : '';
|
|
64
|
+
return {
|
|
65
|
+
type: START_PROVIDER_LOGIN,
|
|
66
|
+
request: { op: 'get', path: `/@login-providers/${providerId}${query}` },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Hand the provider's answer back to the backend.
|
|
72
|
+
*
|
|
73
|
+
* @param provider Provider the code came from.
|
|
74
|
+
* @param code The authorization code.
|
|
75
|
+
* @param state The state the provider echoed back.
|
|
76
|
+
*/
|
|
77
|
+
export function completeCallback(
|
|
78
|
+
provider: string,
|
|
79
|
+
code: string,
|
|
80
|
+
state: string,
|
|
81
|
+
) {
|
|
82
|
+
return {
|
|
83
|
+
type: COMPLETE_CALLBACK,
|
|
84
|
+
request: {
|
|
85
|
+
op: 'post',
|
|
86
|
+
path: '/@identity-callback',
|
|
87
|
+
data: { provider, code, state },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ask for a magic link.
|
|
94
|
+
*
|
|
95
|
+
* @param email Address to send it to.
|
|
96
|
+
*/
|
|
97
|
+
export function sendMagicLink(email: string) {
|
|
98
|
+
return {
|
|
99
|
+
type: SEND_MAGIC_LINK,
|
|
100
|
+
request: { op: 'post', path: '/@magic-link', data: { email } },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Redeem a magic link.
|
|
106
|
+
*
|
|
107
|
+
* @param token The token from the emailed link.
|
|
108
|
+
*/
|
|
109
|
+
export function confirmMagicLink(token: string) {
|
|
110
|
+
return {
|
|
111
|
+
type: CONFIRM_MAGIC_LINK,
|
|
112
|
+
request: {
|
|
113
|
+
op: 'post',
|
|
114
|
+
path: '/@magic-link-confirm',
|
|
115
|
+
data: { token },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read a user, with what this add-on knows about them.
|
|
122
|
+
*
|
|
123
|
+
* `@users/<userid>` is Plone's own endpoint; this package's serializer adds
|
|
124
|
+
* `identities`, `source` and `profile_url` to it, which is what the toolbar
|
|
125
|
+
* and the profile links read. Held separately from Volto's `state.users.user`
|
|
126
|
+
* on purpose: that one is fetched when the user menu opens and cleared
|
|
127
|
+
* around it, and the avatar has to survive a page the menu was never opened
|
|
128
|
+
* on.
|
|
129
|
+
*
|
|
130
|
+
* @param userid The user to read.
|
|
131
|
+
*/
|
|
132
|
+
export function getUserProfile(userid: string) {
|
|
133
|
+
return {
|
|
134
|
+
type: GET_USER_PROFILE,
|
|
135
|
+
request: { op: 'get', path: `/@users/${userid}` },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* List the identities the signed-in user owns.
|
|
141
|
+
*
|
|
142
|
+
* The answer also carries `available`: every *enabled* provider the caller
|
|
143
|
+
* has not linked yet. That is a different question from what the login screen
|
|
144
|
+
* offers -- a provider taken off the login page is still one an existing user
|
|
145
|
+
* may attach -- so it is the endpoint's own answer rather than the expanded
|
|
146
|
+
* `login-providers` listing, which remains available for a caller that wants
|
|
147
|
+
* the login screen's view of things.
|
|
148
|
+
*
|
|
149
|
+
* @param withProviders Whether to expand the login providers alongside.
|
|
150
|
+
*/
|
|
151
|
+
export function listIdentities(withProviders = false) {
|
|
152
|
+
const query = withProviders ? '?expand=login-providers' : '';
|
|
153
|
+
return {
|
|
154
|
+
type: LIST_IDENTITIES,
|
|
155
|
+
request: { op: 'get', path: `/@identities${query}` },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Start a flow that attaches another provider to the caller's account.
|
|
161
|
+
*
|
|
162
|
+
* Answers one of two shapes. A redirect provider gives back an
|
|
163
|
+
* `authorize_url` to follow; the email provider gives back `sent`, because
|
|
164
|
+
* its flow continues in a mailbox rather than in this tab.
|
|
165
|
+
*
|
|
166
|
+
* @param providerId Provider to link.
|
|
167
|
+
* @param cameFrom Where to send the user afterwards.
|
|
168
|
+
* @param email Address to confirm, for the email provider only.
|
|
169
|
+
*/
|
|
170
|
+
export function startLinking(providerId: string, cameFrom = '', email = '') {
|
|
171
|
+
return {
|
|
172
|
+
type: START_LINKING,
|
|
173
|
+
request: {
|
|
174
|
+
op: 'post',
|
|
175
|
+
path: '/@identities',
|
|
176
|
+
// Sent only when there is one: the backend requires an address for the
|
|
177
|
+
// email provider and takes none for any other.
|
|
178
|
+
data: email
|
|
179
|
+
? { provider: providerId, came_from: cameFrom, email }
|
|
180
|
+
: { provider: providerId, came_from: cameFrom },
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Detach one identity.
|
|
187
|
+
*
|
|
188
|
+
* @param provider Provider id.
|
|
189
|
+
* @param subject Provider-side subject.
|
|
190
|
+
*/
|
|
191
|
+
export function unlinkIdentity(provider: string, subject: string) {
|
|
192
|
+
return {
|
|
193
|
+
type: UNLINK_IDENTITY,
|
|
194
|
+
request: {
|
|
195
|
+
op: 'del',
|
|
196
|
+
path: `/@identities/${encodeURIComponent(provider)}/${encodeURIComponent(subject)}`,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* List the members of one group, nested memberships included.
|
|
203
|
+
*
|
|
204
|
+
* plone.restapi's `@groups/<id>` already carries member userids and already
|
|
205
|
+
* sees the nesting; what it cannot do is name each person, search within the
|
|
206
|
+
* group, or say what feeds into it. This does all three.
|
|
207
|
+
*
|
|
208
|
+
* @param groupId The group to read.
|
|
209
|
+
* @param query Case-insensitive substring, matched against name and login.
|
|
210
|
+
*/
|
|
211
|
+
export function listGroupMembers(groupId: string, query = '') {
|
|
212
|
+
const search = query ? `?query=${encodeURIComponent(query)}` : '';
|
|
213
|
+
return {
|
|
214
|
+
type: LIST_GROUP_MEMBERS,
|
|
215
|
+
request: {
|
|
216
|
+
op: 'get',
|
|
217
|
+
path: `/@group-members/${encodeURIComponent(groupId)}${search}`,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read how one account gets in, and when it last did.
|
|
224
|
+
*
|
|
225
|
+
* `Manage users`, except when a caller asks about themselves.
|
|
226
|
+
*
|
|
227
|
+
* @param userid The account to read.
|
|
228
|
+
*/
|
|
229
|
+
export function getUserAccount(userid: string) {
|
|
230
|
+
return {
|
|
231
|
+
type: GET_USER_ACCOUNT,
|
|
232
|
+
request: {
|
|
233
|
+
op: 'get',
|
|
234
|
+
path: `/@user-account/${encodeURIComponent(userid)}`,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* List the drivers, with the schema the control-panel form renders from.
|
|
241
|
+
*/
|
|
242
|
+
export function listDrivers() {
|
|
243
|
+
return {
|
|
244
|
+
type: LIST_DRIVERS,
|
|
245
|
+
request: { op: 'get', path: '/@identity-drivers' },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* List the configured providers.
|
|
251
|
+
*/
|
|
252
|
+
export function listProviders() {
|
|
253
|
+
return {
|
|
254
|
+
type: LIST_PROVIDERS,
|
|
255
|
+
request: { op: 'get', path: '/@identity-providers' },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Create a provider.
|
|
261
|
+
*
|
|
262
|
+
* @param data The provider record.
|
|
263
|
+
*/
|
|
264
|
+
export function createProvider(data: Record<string, unknown>) {
|
|
265
|
+
return {
|
|
266
|
+
type: CREATE_PROVIDER,
|
|
267
|
+
request: { op: 'post', path: '/@identity-providers', data },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Update a provider in place.
|
|
273
|
+
*
|
|
274
|
+
* @param providerId Provider to update.
|
|
275
|
+
* @param data The fields to change.
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* Move one of your addresses to the front of your profile's list.
|
|
279
|
+
*
|
|
280
|
+
* Which address stands for you is the order of that list -- the backend
|
|
281
|
+
* derives `email` from it, preferring the first verified one -- so choosing is
|
|
282
|
+
* a reorder rather than a field of its own. A `PATCH` on the profile is what
|
|
283
|
+
* saving the edit form does, and the same permission covers both.
|
|
284
|
+
*
|
|
285
|
+
* The whole list is sent, not just the address, because that is what the field
|
|
286
|
+
* is: sending one entry would replace the list with it.
|
|
287
|
+
*
|
|
288
|
+
* @param profilePath App-relative path of the profile.
|
|
289
|
+
* @param addresses The addresses in their new order.
|
|
290
|
+
*/
|
|
291
|
+
export function setPreferredEmail(profilePath: string, addresses: string[]) {
|
|
292
|
+
return {
|
|
293
|
+
type: SET_PREFERRED_EMAIL,
|
|
294
|
+
request: {
|
|
295
|
+
op: 'patch',
|
|
296
|
+
path: profilePath,
|
|
297
|
+
data: { emails: addresses },
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function updateProvider(
|
|
303
|
+
providerId: string,
|
|
304
|
+
data: Record<string, unknown>,
|
|
305
|
+
) {
|
|
306
|
+
return {
|
|
307
|
+
type: UPDATE_PROVIDER,
|
|
308
|
+
request: {
|
|
309
|
+
op: 'patch',
|
|
310
|
+
path: `/@identity-providers/${encodeURIComponent(providerId)}`,
|
|
311
|
+
data,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Remove a provider.
|
|
318
|
+
*
|
|
319
|
+
* @param providerId Provider to remove.
|
|
320
|
+
*/
|
|
321
|
+
export function deleteProvider(providerId: string) {
|
|
322
|
+
return {
|
|
323
|
+
type: DELETE_PROVIDER,
|
|
324
|
+
request: {
|
|
325
|
+
op: 'del',
|
|
326
|
+
path: `/@identity-providers/${encodeURIComponent(providerId)}`,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Check that a provider can actually be reached.
|
|
333
|
+
*
|
|
334
|
+
* @param providerId Provider to check.
|
|
335
|
+
*/
|
|
336
|
+
export function testProvider(providerId: string) {
|
|
337
|
+
return {
|
|
338
|
+
type: TEST_PROVIDER,
|
|
339
|
+
request: {
|
|
340
|
+
op: 'post',
|
|
341
|
+
path: `/@identity-providers/${encodeURIComponent(providerId)}/test-connection`,
|
|
342
|
+
data: {},
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Ask where the signed-in user's own Profile is, and how far along it is.
|
|
349
|
+
*
|
|
350
|
+
* Used for first-login routing: a user whose Profile is still `incomplete`
|
|
351
|
+
* should be asked to fill it in before being sent on their way. Answers
|
|
352
|
+
* usably for a user who has no Profile, so the caller does not have to know
|
|
353
|
+
* whether there is one.
|
|
354
|
+
*/
|
|
355
|
+
export function getMyProfile() {
|
|
356
|
+
return {
|
|
357
|
+
type: GET_MY_PROFILE,
|
|
358
|
+
request: { op: 'get', path: '/@my-profile' },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Base path of the OAuth client registry. */
|
|
363
|
+
const CLIENTS = '/@identity-clients';
|
|
364
|
+
|
|
365
|
+
/** Base path of the signing key ring. */
|
|
366
|
+
const KEYS = '/@identity-keys';
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* List the OAuth clients registered against this site.
|
|
370
|
+
*
|
|
371
|
+
* The other direction from `listProviders`: those are who this site lets
|
|
372
|
+
* people log in *with*, these are who may log in *to* it.
|
|
373
|
+
*/
|
|
374
|
+
export function listClients() {
|
|
375
|
+
return {
|
|
376
|
+
type: LIST_CLIENTS,
|
|
377
|
+
request: { op: 'get', path: CLIENTS },
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Register an OAuth client.
|
|
383
|
+
*
|
|
384
|
+
* The answer carries the client secret, and it is the only time it exists:
|
|
385
|
+
* the server stores a hash and cannot read it back. Whatever handles this
|
|
386
|
+
* result has to put it in front of the operator immediately.
|
|
387
|
+
*
|
|
388
|
+
* @param data The registration.
|
|
389
|
+
*/
|
|
390
|
+
export function createClient(data: Record<string, unknown>) {
|
|
391
|
+
return {
|
|
392
|
+
type: CREATE_CLIENT,
|
|
393
|
+
request: { op: 'post', path: CLIENTS, data },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Amend a client registration.
|
|
399
|
+
*
|
|
400
|
+
* `client_id` and `auth_method` are not editable and the backend refuses
|
|
401
|
+
* them, along with any field it does not know: silently dropping one is how
|
|
402
|
+
* somebody comes to believe they changed something they did not.
|
|
403
|
+
*
|
|
404
|
+
* @param clientId The client to amend.
|
|
405
|
+
* @param data The fields to change.
|
|
406
|
+
*/
|
|
407
|
+
export function updateClient(clientId: string, data: Record<string, unknown>) {
|
|
408
|
+
return {
|
|
409
|
+
type: UPDATE_CLIENT,
|
|
410
|
+
request: {
|
|
411
|
+
op: 'patch',
|
|
412
|
+
path: `${CLIENTS}/${encodeURIComponent(clientId)}`,
|
|
413
|
+
data,
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Unregister a client.
|
|
420
|
+
*
|
|
421
|
+
* Also a revocation: access tokens carry the client id as their audience and
|
|
422
|
+
* it is checked against the registry on every request, so this stops its
|
|
423
|
+
* tokens working at once.
|
|
424
|
+
*
|
|
425
|
+
* @param clientId The client to remove.
|
|
426
|
+
*/
|
|
427
|
+
export function deleteClient(clientId: string) {
|
|
428
|
+
return {
|
|
429
|
+
type: DELETE_CLIENT,
|
|
430
|
+
request: { op: 'del', path: `${CLIENTS}/${encodeURIComponent(clientId)}` },
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Mint a client a fresh secret, discarding the old one.
|
|
436
|
+
*
|
|
437
|
+
* As with registration the answer carries the only copy.
|
|
438
|
+
*
|
|
439
|
+
* @param clientId The client to rotate.
|
|
440
|
+
*/
|
|
441
|
+
export function rotateClientSecret(clientId: string) {
|
|
442
|
+
return {
|
|
443
|
+
type: ROTATE_CLIENT_SECRET,
|
|
444
|
+
request: {
|
|
445
|
+
op: 'post',
|
|
446
|
+
path: `${CLIENTS}/${encodeURIComponent(clientId)}/rotate-secret`,
|
|
447
|
+
data: {},
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Describe the signing key ring. Metadata only; never key material. */
|
|
453
|
+
export function listKeys() {
|
|
454
|
+
return {
|
|
455
|
+
type: LIST_KEYS,
|
|
456
|
+
request: { op: 'get', path: KEYS },
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Rotate the signing key.
|
|
462
|
+
*
|
|
463
|
+
* Previous keys stay in the ring so tokens already issued keep verifying by
|
|
464
|
+
* `kid`. The ring is bounded, so rotating past that bound inside one
|
|
465
|
+
* access-token lifetime does invalidate tokens still in flight.
|
|
466
|
+
*/
|
|
467
|
+
export function rotateKey() {
|
|
468
|
+
return {
|
|
469
|
+
type: ROTATE_KEY,
|
|
470
|
+
request: { op: 'post', path: `${KEYS}/rotate`, data: {} },
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Describe the authorization request the browser arrived with.
|
|
476
|
+
*
|
|
477
|
+
* Read-only, and deliberately so: this records no consent and issues no
|
|
478
|
+
* code. The answer goes back to the authorization endpoint, which decides
|
|
479
|
+
* again from scratch -- a client disabled between the question and the
|
|
480
|
+
* answer is refused on the way out as surely as on the way in.
|
|
481
|
+
*
|
|
482
|
+
* @param search The query string the consent route was reached with,
|
|
483
|
+
* including its leading `?`. Passed straight through rather than rebuilt
|
|
484
|
+
* from parsed parameters: an authorization request is compared byte for
|
|
485
|
+
* byte further down the flow, and a round trip through a parser is a
|
|
486
|
+
* chance to change it.
|
|
487
|
+
*/
|
|
488
|
+
export function getConsentRequest(search: string) {
|
|
489
|
+
return {
|
|
490
|
+
type: GET_CONSENT_REQUEST,
|
|
491
|
+
request: { op: 'get', path: `/@oauth-consent${search}` },
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Base path of the caller's own OAuth grants. */
|
|
496
|
+
const GRANTS = '/@oauth-grants';
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* List the applications the caller has authorized.
|
|
500
|
+
*
|
|
501
|
+
* The counterpart of `listIdentities`: that answers which providers they
|
|
502
|
+
* sign in with, this which applications they signed in to.
|
|
503
|
+
*/
|
|
504
|
+
export function listGrants() {
|
|
505
|
+
return {
|
|
506
|
+
type: LIST_GRANTS,
|
|
507
|
+
request: { op: 'get', path: GRANTS },
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Withdraw the caller's agreement with one client.
|
|
513
|
+
*
|
|
514
|
+
* The backend forgets the agreement *and* revokes that client's refresh
|
|
515
|
+
* tokens for this user, because forgetting alone would only decide the next
|
|
516
|
+
* authorization. It answers with how many sessions ended and how long an
|
|
517
|
+
* access token already minted may still work, neither of which this can
|
|
518
|
+
* know on its own.
|
|
519
|
+
*
|
|
520
|
+
* @param clientId The client to withdraw from.
|
|
521
|
+
*/
|
|
522
|
+
export function withdrawGrant(clientId: string) {
|
|
523
|
+
return {
|
|
524
|
+
type: WITHDRAW_GRANT,
|
|
525
|
+
request: {
|
|
526
|
+
op: 'del',
|
|
527
|
+
path: `${GRANTS}/${encodeURIComponent(clientId)}`,
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applications container: the authorized-applications page in user settings.
|
|
3
|
+
*
|
|
4
|
+
* Chrome and store, the way the identities page has them; the panel below
|
|
5
|
+
* does the rendering. This one adds the confirmation, because withdrawing is
|
|
6
|
+
* the destructive half and the person doing it should know what it costs
|
|
7
|
+
* before it happens rather than read about it afterwards.
|
|
8
|
+
* @module components/Applications/Applications
|
|
9
|
+
*/
|
|
10
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
11
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
12
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
13
|
+
import { createPortal } from 'react-dom';
|
|
14
|
+
import { Container, Segment } from 'semantic-ui-react';
|
|
15
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
16
|
+
import { toast } from 'react-toastify';
|
|
17
|
+
|
|
18
|
+
import { Helmet } from '@plone/volto/helpers/Helmet/Helmet';
|
|
19
|
+
import { useClient } from '@plone/volto/hooks/client/useClient';
|
|
20
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
21
|
+
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
|
|
22
|
+
import Toast from '@plone/volto/components/manage/Toast/Toast';
|
|
23
|
+
import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
|
|
24
|
+
import backSVG from '@plone/volto/icons/back.svg';
|
|
25
|
+
|
|
26
|
+
import { listGrants, withdrawGrant } from '../../actions';
|
|
27
|
+
import ApplicationsPanel from './ApplicationsPanel';
|
|
28
|
+
import ConfirmModal from '../ControlPanel/ConfirmModal';
|
|
29
|
+
|
|
30
|
+
const messages = defineMessages({
|
|
31
|
+
title: { id: 'Applications', defaultMessage: 'Applications' },
|
|
32
|
+
back: { id: 'Back', defaultMessage: 'Back' },
|
|
33
|
+
confirm: {
|
|
34
|
+
id: 'Withdraw access for {client}?',
|
|
35
|
+
defaultMessage:
|
|
36
|
+
'Withdraw access for {client}? It will be signed out everywhere and ' +
|
|
37
|
+
'will have to ask you again next time.',
|
|
38
|
+
},
|
|
39
|
+
withdraw: { id: 'Withdraw access', defaultMessage: 'Withdraw access' },
|
|
40
|
+
withdrawn: {
|
|
41
|
+
id: 'Access withdrawn',
|
|
42
|
+
defaultMessage: 'Access withdrawn',
|
|
43
|
+
},
|
|
44
|
+
error: { id: 'Error', defaultMessage: 'Error' },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const Applications: React.FC = () => {
|
|
48
|
+
const intl = useIntl();
|
|
49
|
+
const dispatch = useDispatch();
|
|
50
|
+
const isClient = useClient();
|
|
51
|
+
const { pathname } = useLocation();
|
|
52
|
+
// Which client is being withdrawn, so only that row's button goes dead
|
|
53
|
+
// rather than the whole list.
|
|
54
|
+
const [withdrawing, setWithdrawing] = useState<string | null>(null);
|
|
55
|
+
// Which one's details are on screen. Held here rather than in the URL: a
|
|
56
|
+
// detail view is a way of reading one row of this page, not a page of its
|
|
57
|
+
// own, and a bookmark to somebody's authorization of one application is
|
|
58
|
+
// not a thing worth being able to share.
|
|
59
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
60
|
+
|
|
61
|
+
const grants = useSelector((state: any) => state.oauthGrants);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
dispatch(listGrants());
|
|
65
|
+
}, [dispatch]);
|
|
66
|
+
|
|
67
|
+
// Which application the question is being asked about, or null when it is
|
|
68
|
+
// not being asked. A blocking browser dialog was doing this, which froze
|
|
69
|
+
// the page and read as a different application than the one around it.
|
|
70
|
+
const [confirming, setConfirming] = useState<{
|
|
71
|
+
clientId: string;
|
|
72
|
+
title: string;
|
|
73
|
+
} | null>(null);
|
|
74
|
+
|
|
75
|
+
const onWithdraw = useCallback(
|
|
76
|
+
(clientId: string) => {
|
|
77
|
+
const grant = grants?.data?.items?.find(
|
|
78
|
+
(item: { client_id: string }) => item.client_id === clientId,
|
|
79
|
+
);
|
|
80
|
+
setConfirming({ clientId, title: grant?.title || clientId });
|
|
81
|
+
},
|
|
82
|
+
[grants],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const onConfirmWithdraw = useCallback(() => {
|
|
86
|
+
const asked = confirming;
|
|
87
|
+
setConfirming(null);
|
|
88
|
+
if (!asked) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const clientId = asked.clientId;
|
|
92
|
+
setWithdrawing(clientId);
|
|
93
|
+
(dispatch(withdrawGrant(clientId)) as any)
|
|
94
|
+
.then(() => {
|
|
95
|
+
toast.success(
|
|
96
|
+
<Toast success title={intl.formatMessage(messages.withdrawn)} />,
|
|
97
|
+
);
|
|
98
|
+
// Back to the list: the application whose details were on screen
|
|
99
|
+
// is the one that just stopped existing.
|
|
100
|
+
setSelected(null);
|
|
101
|
+
// Re-read rather than drop the row locally: the listing is what
|
|
102
|
+
// says whether anything is left, and a locally pruned one would
|
|
103
|
+
// disagree with the server the moment anything else changed.
|
|
104
|
+
dispatch(listGrants());
|
|
105
|
+
})
|
|
106
|
+
.catch((err: any) => {
|
|
107
|
+
toast.error(
|
|
108
|
+
<Toast
|
|
109
|
+
error
|
|
110
|
+
title={intl.formatMessage(messages.error)}
|
|
111
|
+
content={err?.response?.body?.error?.message ?? String(err)}
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
})
|
|
115
|
+
.finally(() => setWithdrawing(null));
|
|
116
|
+
}, [confirming, dispatch, intl]);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Container id="page-applications">
|
|
120
|
+
<ConfirmModal
|
|
121
|
+
open={confirming !== null}
|
|
122
|
+
header={confirming?.title ?? ''}
|
|
123
|
+
content={intl.formatMessage(messages.confirm, {
|
|
124
|
+
client: confirming?.title ?? '',
|
|
125
|
+
})}
|
|
126
|
+
confirmLabel={intl.formatMessage(messages.withdraw)}
|
|
127
|
+
onCancel={() => setConfirming(null)}
|
|
128
|
+
onConfirm={onConfirmWithdraw}
|
|
129
|
+
/>
|
|
130
|
+
<Helmet title={intl.formatMessage(messages.title)} />
|
|
131
|
+
<Segment.Group raised>
|
|
132
|
+
<Segment className="primary">
|
|
133
|
+
{intl.formatMessage(messages.title)}
|
|
134
|
+
</Segment>
|
|
135
|
+
<Segment>
|
|
136
|
+
<ApplicationsPanel
|
|
137
|
+
grants={grants?.data ?? null}
|
|
138
|
+
loading={Boolean(grants?.loading)}
|
|
139
|
+
error={grants?.error}
|
|
140
|
+
selected={selected}
|
|
141
|
+
withdrawing={withdrawing}
|
|
142
|
+
onSelect={setSelected}
|
|
143
|
+
onWithdraw={onWithdraw}
|
|
144
|
+
/>
|
|
145
|
+
</Segment>
|
|
146
|
+
</Segment.Group>
|
|
147
|
+
{isClient &&
|
|
148
|
+
createPortal(
|
|
149
|
+
<Toolbar
|
|
150
|
+
pathname={pathname}
|
|
151
|
+
hideDefaultViewButtons
|
|
152
|
+
inner={
|
|
153
|
+
<Link to={`${getBaseUrl(pathname)}`} className="item">
|
|
154
|
+
<Icon
|
|
155
|
+
name={backSVG}
|
|
156
|
+
className="contents circled"
|
|
157
|
+
size="30px"
|
|
158
|
+
title={intl.formatMessage(messages.back)}
|
|
159
|
+
/>
|
|
160
|
+
</Link>
|
|
161
|
+
}
|
|
162
|
+
/>,
|
|
163
|
+
document.getElementById('toolbar') as HTMLElement,
|
|
164
|
+
)}
|
|
165
|
+
</Container>
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export default Applications;
|