@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,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client form is composed, not invented.
|
|
3
|
+
*
|
|
4
|
+
* What used to be asserted here — which grants exist, what each field is
|
|
5
|
+
* called, which of them are text boxes — is the backend's answer now,
|
|
6
|
+
* serialized by `plone.restapi` from `IClientRecords`. So the grants offered
|
|
7
|
+
* are exactly what the token endpoint implements, without this file holding a
|
|
8
|
+
* list that could drift from it.
|
|
9
|
+
*
|
|
10
|
+
* What is left to test is the composition, and the two questions that exist
|
|
11
|
+
* only while a client is being registered.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
EDITABLE,
|
|
17
|
+
clientSchema,
|
|
18
|
+
fromFormData,
|
|
19
|
+
toFormData,
|
|
20
|
+
} from './clientSchema';
|
|
21
|
+
import type { OAuthClient } from '../types';
|
|
22
|
+
|
|
23
|
+
const intl = {
|
|
24
|
+
formatMessage: (m: { defaultMessage: string }) => m.defaultMessage,
|
|
25
|
+
} as any;
|
|
26
|
+
|
|
27
|
+
/** What `@identity-clients` sends: `IClientRecords`, serialized. */
|
|
28
|
+
const SERVED = {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
title: { title: 'Title', type: 'string' },
|
|
32
|
+
enabled: { title: 'Enabled', type: 'boolean' },
|
|
33
|
+
redirect_uris: { title: 'Redirect URIs', type: 'array', widget: 'token' },
|
|
34
|
+
grant_types: {
|
|
35
|
+
title: 'Grants',
|
|
36
|
+
type: 'array',
|
|
37
|
+
choices: [
|
|
38
|
+
['authorization_code', 'authorization_code'],
|
|
39
|
+
['client_credentials', 'client_credentials'],
|
|
40
|
+
['refresh_token', 'refresh_token'],
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
scope: { title: 'Scopes', type: 'array' },
|
|
44
|
+
service_user: { title: 'Acts as', type: 'string' },
|
|
45
|
+
},
|
|
46
|
+
required: ['title'],
|
|
47
|
+
fieldsets: [
|
|
48
|
+
{ id: 'default', title: 'Default', fields: ['title', 'enabled'] },
|
|
49
|
+
{
|
|
50
|
+
id: 'flow',
|
|
51
|
+
title: 'Flow',
|
|
52
|
+
fields: ['redirect_uris', 'grant_types', 'scope', 'service_user'],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('clientSchema', () => {
|
|
58
|
+
it('carries the served properties through untouched', () => {
|
|
59
|
+
const schema = clientSchema(SERVED, false, intl);
|
|
60
|
+
|
|
61
|
+
expect(schema.properties.grant_types).toEqual(
|
|
62
|
+
SERVED.properties.grant_types,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('offers exactly the grants the backend implements', () => {
|
|
67
|
+
// Not a list this file keeps in step by hand. The vocabulary is built on
|
|
68
|
+
// the backend from what the token endpoint serves and what discovery
|
|
69
|
+
// advertises, so the three can no longer disagree.
|
|
70
|
+
const schema = clientSchema(SERVED, true, intl);
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
(schema.properties.grant_types as any).choices.map(
|
|
74
|
+
([value]: [string, string]) => value,
|
|
75
|
+
),
|
|
76
|
+
).toEqual(['authorization_code', 'client_credentials', 'refresh_token']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('asks for the permanent id only when registering', () => {
|
|
80
|
+
expect(clientSchema(SERVED, true, intl).properties.client_id).toBeTruthy();
|
|
81
|
+
expect(
|
|
82
|
+
clientSchema(SERVED, false, intl).properties.client_id,
|
|
83
|
+
).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('requires the id it cannot invent', () => {
|
|
87
|
+
expect(clientSchema(SERVED, true, intl).required).toContain('client_id');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('offers whether a client is public only at registration', () => {
|
|
91
|
+
// It decides whether a secret is minted, which is a fact about
|
|
92
|
+
// registration rather than a setting.
|
|
93
|
+
expect(clientSchema(SERVED, true, intl).properties.public).toBeTruthy();
|
|
94
|
+
expect(clientSchema(SERVED, false, intl).properties.public).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('asks nothing an edit cannot change', () => {
|
|
98
|
+
const schema = clientSchema(SERVED, false, intl);
|
|
99
|
+
const offered = schema.fieldsets.flatMap((f) => f.fields);
|
|
100
|
+
|
|
101
|
+
expect(offered.every((field) => EDITABLE.includes(field))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('survives a backend that sent no schema', () => {
|
|
105
|
+
// An empty form is recoverable; a crash on `schema.fieldsets` is not.
|
|
106
|
+
const schema = clientSchema(undefined, true, intl);
|
|
107
|
+
|
|
108
|
+
expect(schema.fieldsets).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('toFormData', () => {
|
|
113
|
+
it('starts a registration on the grant that makes it work', () => {
|
|
114
|
+
expect(toFormData().grant_types).toEqual(['authorization_code']);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('edits a scope as the list of permissions it is', () => {
|
|
118
|
+
// The backend field is a tuple of choices, so nothing here splits a
|
|
119
|
+
// string any more -- the two conversions this helper used to do existed
|
|
120
|
+
// only because the record was one line of space-separated text.
|
|
121
|
+
const client = { scope: ['openid', 'profile', 'email'] } as OAuthClient;
|
|
122
|
+
|
|
123
|
+
expect(toFormData(client).scope).toEqual(['openid', 'profile', 'email']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('copies the scopes rather than sharing them', () => {
|
|
127
|
+
// The form mutates what it is given, and the client in the store is not
|
|
128
|
+
// the form's to edit.
|
|
129
|
+
const client = { scope: ['openid'] } as OAuthClient;
|
|
130
|
+
|
|
131
|
+
expect(toFormData(client).scope).not.toBe(client.scope);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('has no scopes for a client that carries none', () => {
|
|
135
|
+
expect(toFormData({} as OAuthClient).scope).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('fromFormData', () => {
|
|
140
|
+
it('sends the scopes as the list the record now holds', () => {
|
|
141
|
+
const payload = fromFormData({ scope: ['openid', 'email'] }, true);
|
|
142
|
+
|
|
143
|
+
expect(payload.scope).toEqual(['openid', 'email']);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('drops the scope widget’s empty rows too', () => {
|
|
147
|
+
const payload = fromFormData({ scope: ['openid', '', null] }, true);
|
|
148
|
+
|
|
149
|
+
expect(payload.scope).toEqual(['openid']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('drops a list widget’s empty rows', () => {
|
|
153
|
+
const payload = fromFormData(
|
|
154
|
+
{ redirect_uris: ['https://a.example/cb', '', null] },
|
|
155
|
+
true,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
expect(payload.redirect_uris).toEqual(['https://a.example/cb']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does not judge a redirect URI', () => {
|
|
162
|
+
// Whether this server will send a browser there is decided on the backend
|
|
163
|
+
// field, which refuses it and says why. Correcting it here would only
|
|
164
|
+
// hide which value the refusal is about.
|
|
165
|
+
const payload = fromFormData(
|
|
166
|
+
{ redirect_uris: [' https://a.example/cb#frag '] },
|
|
167
|
+
true,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(payload.redirect_uris).toEqual([' https://a.example/cb#frag ']);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('sends only what a PATCH accepts', () => {
|
|
174
|
+
// The backend refuses the whole request when it sees a field it will not
|
|
175
|
+
// change, so the two lists agreeing is what keeps an edit from failing
|
|
176
|
+
// wholesale.
|
|
177
|
+
const payload = fromFormData({ title: 'x', client_id: 'nope' }, false);
|
|
178
|
+
|
|
179
|
+
expect(payload.client_id).toBeUndefined();
|
|
180
|
+
expect(Object.keys(payload).every((k) => EDITABLE.includes(k))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth client form, composed from what the backend already sent.
|
|
3
|
+
*
|
|
4
|
+
* This file used to build the schema: its own grant list, its own labels, its
|
|
5
|
+
* own English. The backend serves one now, produced by `plone.restapi` from
|
|
6
|
+
* `IClientRecords` — the interface the client's own storage is described by —
|
|
7
|
+
* so the grants offered are exactly what the token endpoint implements, and
|
|
8
|
+
* every label is translated in the site's language.
|
|
9
|
+
*
|
|
10
|
+
* What is left is composition and the two questions that exist only while a
|
|
11
|
+
* client is being registered: the permanent `client_id`, and whether the
|
|
12
|
+
* client is public. Neither is a stored field an operator edits — the first
|
|
13
|
+
* becomes the record prefix and the second decides whether a secret is minted
|
|
14
|
+
* — so neither is in the schema, and both disappear from the edit form.
|
|
15
|
+
* @module helpers/clientSchema
|
|
16
|
+
*/
|
|
17
|
+
import { defineMessages } from 'react-intl';
|
|
18
|
+
|
|
19
|
+
import type { JsonSchema, OAuthClient, VoltoSchema } from '../types';
|
|
20
|
+
|
|
21
|
+
import type { IntlShape } from 'react-intl';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fields a `PATCH` may change.
|
|
25
|
+
*
|
|
26
|
+
* Mirrors `EDITABLE` in the backend's patch service, which refuses anything
|
|
27
|
+
* else outright rather than dropping it silently. `client_id` and the auth
|
|
28
|
+
* method are absent from both: changing either means registering a new
|
|
29
|
+
* client, and the form says so rather than offering a field the backend
|
|
30
|
+
* would reject.
|
|
31
|
+
*/
|
|
32
|
+
export const EDITABLE = [
|
|
33
|
+
'title',
|
|
34
|
+
'redirect_uris',
|
|
35
|
+
'grant_types',
|
|
36
|
+
'scope',
|
|
37
|
+
'enabled',
|
|
38
|
+
'service_user',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const messages = defineMessages({
|
|
42
|
+
registration: { id: 'Registration', defaultMessage: 'Registration' },
|
|
43
|
+
clientId: { id: 'Client ID', defaultMessage: 'Client ID' },
|
|
44
|
+
clientIdHelp: {
|
|
45
|
+
id: 'client-id-help',
|
|
46
|
+
defaultMessage:
|
|
47
|
+
'Permanent. It is what every token already issued is bound to, so it ' +
|
|
48
|
+
'cannot be changed afterwards.',
|
|
49
|
+
},
|
|
50
|
+
publicClient: { id: 'Public client', defaultMessage: 'Public client' },
|
|
51
|
+
publicClientHelp: {
|
|
52
|
+
id: 'client-public-help',
|
|
53
|
+
defaultMessage:
|
|
54
|
+
'A native or browser app, which cannot keep a secret. No secret is ' +
|
|
55
|
+
'minted and PKCE becomes mandatory instead.',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the client form from the schema the backend sent.
|
|
61
|
+
*
|
|
62
|
+
* @param served The schema from `@identity-clients`.
|
|
63
|
+
* @param adding Whether this registers a client, which asks two more
|
|
64
|
+
* questions than an edit does.
|
|
65
|
+
* @param intl For the labels of the two registration-only fields.
|
|
66
|
+
* @returns A Volto schema.
|
|
67
|
+
*/
|
|
68
|
+
export function clientSchema(
|
|
69
|
+
served: JsonSchema | undefined,
|
|
70
|
+
adding: boolean,
|
|
71
|
+
intl: IntlShape,
|
|
72
|
+
): VoltoSchema {
|
|
73
|
+
const properties: Record<string, Record<string, unknown>> = {};
|
|
74
|
+
const fieldsets: { id: string; title: string; fields: string[] }[] = [];
|
|
75
|
+
const required = [...(served?.required ?? [])];
|
|
76
|
+
|
|
77
|
+
if (adding) {
|
|
78
|
+
properties.client_id = {
|
|
79
|
+
title: intl.formatMessage(messages.clientId),
|
|
80
|
+
description: intl.formatMessage(messages.clientIdHelp),
|
|
81
|
+
type: 'string',
|
|
82
|
+
};
|
|
83
|
+
properties.public = {
|
|
84
|
+
title: intl.formatMessage(messages.publicClient),
|
|
85
|
+
description: intl.formatMessage(messages.publicClientHelp),
|
|
86
|
+
type: 'boolean',
|
|
87
|
+
};
|
|
88
|
+
required.push('client_id');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
Object.assign(properties, served?.properties ?? {});
|
|
92
|
+
|
|
93
|
+
(served?.fieldsets ?? []).forEach((fieldset, index) => {
|
|
94
|
+
const fields =
|
|
95
|
+
index === 0
|
|
96
|
+
? [
|
|
97
|
+
...(adding ? ['client_id'] : []),
|
|
98
|
+
...fieldset.fields.filter(
|
|
99
|
+
(name) => adding || EDITABLE.includes(name),
|
|
100
|
+
),
|
|
101
|
+
...(adding ? ['public'] : []),
|
|
102
|
+
]
|
|
103
|
+
: fieldset.fields.filter((name) => adding || EDITABLE.includes(name));
|
|
104
|
+
if (fields.length) {
|
|
105
|
+
fieldsets.push({
|
|
106
|
+
id: fieldset.id,
|
|
107
|
+
title:
|
|
108
|
+
index === 0 && adding
|
|
109
|
+
? intl.formatMessage(messages.registration)
|
|
110
|
+
: fieldset.title,
|
|
111
|
+
fields,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { fieldsets, properties, required };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Turn a client from the API into form data.
|
|
121
|
+
*
|
|
122
|
+
* @param client The client, or undefined when registering.
|
|
123
|
+
* @returns Form data.
|
|
124
|
+
*/
|
|
125
|
+
export function toFormData(client?: OAuthClient): Record<string, unknown> {
|
|
126
|
+
if (!client) {
|
|
127
|
+
// `Form` seeds the rest from the schema's own defaults; what it cannot
|
|
128
|
+
// know is that a new registration is meant to work, which is what the
|
|
129
|
+
// authorization-code grant amounts to.
|
|
130
|
+
return { grant_types: ['authorization_code'], public: false };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
title: client.title ?? '',
|
|
134
|
+
enabled: client.enabled ?? true,
|
|
135
|
+
grant_types: [...(client.grant_types ?? [])],
|
|
136
|
+
redirect_uris: [...(client.redirect_uris ?? [])],
|
|
137
|
+
scope: [...(client.scope ?? [])],
|
|
138
|
+
service_user: client.service_user ?? '',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Turn submitted form data back into the API payload.
|
|
144
|
+
*
|
|
145
|
+
* Shape only. Whether a redirect URI is one this server will send a browser
|
|
146
|
+
* to is decided on the backend field, which refuses a fragment, a wildcard, a
|
|
147
|
+
* `javascript:` scheme and plain HTTP off the loopback -- and says which. The
|
|
148
|
+
* blank entries dropped below are a list widget's empty rows, not a judgement
|
|
149
|
+
* about any value somebody typed.
|
|
150
|
+
*
|
|
151
|
+
* @param formData What the form submitted.
|
|
152
|
+
* @param adding Whether this registers a client; an edit sends only what
|
|
153
|
+
* the backend accepts, since it refuses the whole request otherwise.
|
|
154
|
+
* @returns The body for POST or PATCH.
|
|
155
|
+
*/
|
|
156
|
+
export function fromFormData(
|
|
157
|
+
formData: Record<string, any>,
|
|
158
|
+
adding: boolean,
|
|
159
|
+
): Record<string, unknown> {
|
|
160
|
+
const data = formData ?? {};
|
|
161
|
+
const payload: Record<string, unknown> = {
|
|
162
|
+
title: data.title ?? '',
|
|
163
|
+
grant_types: [...(data.grant_types ?? [])],
|
|
164
|
+
redirect_uris: (data.redirect_uris ?? []).filter(Boolean),
|
|
165
|
+
scope: (data.scope ?? []).filter(Boolean),
|
|
166
|
+
service_user: data.service_user ?? '',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (adding) {
|
|
170
|
+
payload.client_id = String(data.client_id ?? '').trim();
|
|
171
|
+
payload.public = Boolean(data.public);
|
|
172
|
+
return payload;
|
|
173
|
+
}
|
|
174
|
+
payload.enabled = Boolean(data.enabled);
|
|
175
|
+
// Filtered rather than assembled a second time: the backend refuses the
|
|
176
|
+
// whole request when it sees a field it will not change, so the two lists
|
|
177
|
+
// agreeing is what keeps an edit from failing wholesale.
|
|
178
|
+
return Object.fromEntries(
|
|
179
|
+
Object.entries(payload).filter(([field]) => EDITABLE.includes(field)),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { afterLogin, toAppPath } from './firstLogin';
|
|
4
|
+
import type { MyProfile } from '../types';
|
|
5
|
+
|
|
6
|
+
function profile(overrides: Partial<MyProfile> = {}): MyProfile {
|
|
7
|
+
return {
|
|
8
|
+
'@id': 'http://backend:8080/Plone/@my-profile',
|
|
9
|
+
userid: 'alice-userid',
|
|
10
|
+
profile: 'http://backend:8080/Plone/identity-profiles/alice-userid',
|
|
11
|
+
review_state: 'incomplete',
|
|
12
|
+
missing: [],
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('toAppPath', () => {
|
|
18
|
+
it('strips a configured backend base', () => {
|
|
19
|
+
expect(
|
|
20
|
+
toAppPath(
|
|
21
|
+
'http://backend:8080/Plone/identity-profiles/alice',
|
|
22
|
+
'http://backend:8080/Plone',
|
|
23
|
+
),
|
|
24
|
+
).toBe('/identity-profiles/alice');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('strips the origin when there is no configured base', () => {
|
|
28
|
+
expect(toAppPath('https://example.com/identity-profiles/alice')).toBe(
|
|
29
|
+
'/identity-profiles/alice',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('leaves an already relative path alone', () => {
|
|
34
|
+
expect(toAppPath('/identity-profiles/alice')).toBe(
|
|
35
|
+
'/identity-profiles/alice',
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('adds the leading slash a relative answer may lack', () => {
|
|
40
|
+
expect(toAppPath('identity-profiles/alice')).toBe(
|
|
41
|
+
'/identity-profiles/alice',
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('handles an origin with nothing after it', () => {
|
|
46
|
+
expect(toAppPath('https://example.com')).toBe('/');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('treats an empty URL as the site root', () => {
|
|
50
|
+
expect(toAppPath('')).toBe('/');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('afterLogin', () => {
|
|
55
|
+
it('diverts a user whose profile is incomplete to its edit form', () => {
|
|
56
|
+
// The edit form rather than the profile: it is missing something the
|
|
57
|
+
// site requires, and the view would ask for one more click to reach the
|
|
58
|
+
// only thing they can do about that.
|
|
59
|
+
expect(afterLogin(profile(), '/news', 'http://backend:8080/Plone')).toBe(
|
|
60
|
+
'/identity-profiles/alice-userid/edit',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('leaves a completed profile alone', () => {
|
|
65
|
+
expect(afterLogin(profile({ review_state: 'complete' }), '/news')).toBe(
|
|
66
|
+
'/news',
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not divert a deactivated profile', () => {
|
|
71
|
+
// Diverting here would send somebody to a Profile they cannot even view.
|
|
72
|
+
expect(afterLogin(profile({ review_state: 'deactivated' }), '/news')).toBe(
|
|
73
|
+
'/news',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('carries on when the user has no profile', () => {
|
|
78
|
+
expect(
|
|
79
|
+
afterLogin(profile({ profile: null, review_state: null }), '/news'),
|
|
80
|
+
).toBe('/news');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('carries on in a site without the extra installed', () => {
|
|
84
|
+
expect(afterLogin(null, '/news')).toBe('/news');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('carries on before the answer has loaded', () => {
|
|
88
|
+
expect(afterLogin(undefined, '/news')).toBe('/news');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding where a just-signed-in user lands when the site has Profiles.
|
|
3
|
+
*
|
|
4
|
+
* Pure, and separate from the container, for the same reason `returnUrl` is:
|
|
5
|
+
* the decision is small, it is easy to get subtly wrong, and every branch of
|
|
6
|
+
* it deserves a test that does not need a store.
|
|
7
|
+
* @module helpers/firstLogin
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { rememberReturn } from './profileGate';
|
|
11
|
+
|
|
12
|
+
import type { MyProfile } from '../types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Turn an absolute backend URL into a path this Volto app can route to.
|
|
16
|
+
*
|
|
17
|
+
* The backend answers with the URL it is served under, which in a split
|
|
18
|
+
* deployment is not the URL the browser is on. Passing that straight to
|
|
19
|
+
* `history.push` would navigate off the frontend and land the user on the
|
|
20
|
+
* backend's own rendering of the Profile.
|
|
21
|
+
*
|
|
22
|
+
* @param url Absolute or relative URL from the backend.
|
|
23
|
+
* @param apiPath The backend's base URL, when it differs from the frontend's.
|
|
24
|
+
* @returns A site-relative path, always beginning with a slash.
|
|
25
|
+
*/
|
|
26
|
+
export function toAppPath(url: string, apiPath = ''): string {
|
|
27
|
+
if (!url) {
|
|
28
|
+
return '/';
|
|
29
|
+
}
|
|
30
|
+
let path = url;
|
|
31
|
+
if (apiPath && path.startsWith(apiPath)) {
|
|
32
|
+
path = path.slice(apiPath.length);
|
|
33
|
+
} else {
|
|
34
|
+
// No configured base, or the answer came from somewhere else: fall back to
|
|
35
|
+
// stripping the origin, which is all a path needs.
|
|
36
|
+
const match = /^https?:\/\/[^/]+(\/.*)?$/.exec(path);
|
|
37
|
+
if (match) {
|
|
38
|
+
path = match[1] ?? '/';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Work out where to send a user who has just signed in.
|
|
46
|
+
*
|
|
47
|
+
* Only an `incomplete` Profile diverts them. Every other answer — no Profile,
|
|
48
|
+
* a `complete` one, a `deactivated` one, a site without the extra installed —
|
|
49
|
+
* means "carry on where you were going". Diverting on anything else would
|
|
50
|
+
* trap a user with a deliberately sparse profile in a loop they cannot leave.
|
|
51
|
+
*
|
|
52
|
+
* They land on the **edit form** rather than on the profile. The profile is
|
|
53
|
+
* missing something the site requires, and a read-only page showing them that
|
|
54
|
+
* asks for an extra click to reach the only thing they can do about it.
|
|
55
|
+
*
|
|
56
|
+
* @param profile The `@my-profile` answer, or null before it has loaded.
|
|
57
|
+
* @param fallback Where the user was heading.
|
|
58
|
+
* @param apiPath The backend's base URL, when it differs from the frontend's.
|
|
59
|
+
* @returns The path to navigate to.
|
|
60
|
+
*/
|
|
61
|
+
export function afterLogin(
|
|
62
|
+
profile: MyProfile | null | undefined,
|
|
63
|
+
fallback: string,
|
|
64
|
+
apiPath = '',
|
|
65
|
+
): string {
|
|
66
|
+
if (!profile?.profile || profile.review_state !== 'incomplete') {
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
// Where they were heading is not thrown away, it is parked. `ProfileGate`
|
|
70
|
+
// restores it the moment the profile stops being incomplete, which is what
|
|
71
|
+
// turns "you must fill this in first" into a detour rather than a dead end.
|
|
72
|
+
// In the demo that dead end was a user stranded on the identity provider,
|
|
73
|
+
// mid-way through signing in to a different site.
|
|
74
|
+
rememberReturn(fallback);
|
|
75
|
+
return `${toAppPath(profile.profile, apiPath)}/edit`;
|
|
76
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { fromGroupRows, toGroupRows } from './groupmap';
|
|
4
|
+
|
|
5
|
+
describe('toGroupRows', () => {
|
|
6
|
+
it('turns a map into rows', () => {
|
|
7
|
+
expect(toGroupRows({ editors: 'site-editors' })).toEqual([
|
|
8
|
+
{ '@id': expect.any(String), group: 'editors', local: 'site-editors' },
|
|
9
|
+
]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('gives every row an @id', () => {
|
|
13
|
+
// ObjectListWidget keys its drag list on it and renders nothing for a
|
|
14
|
+
// row without one -- a silent empty list, not an error.
|
|
15
|
+
const rows = toGroupRows({ editors: 'site-editors', staff: 'site-staff' });
|
|
16
|
+
|
|
17
|
+
expect(rows.every((row) => row['@id'])).toBe(true);
|
|
18
|
+
expect(rows[0]['@id']).not.toEqual(rows[1]['@id']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('keeps the stored order', () => {
|
|
22
|
+
const rows = toGroupRows({ staff: 'site-staff', editors: 'site-editors' });
|
|
23
|
+
|
|
24
|
+
expect(rows.map((row) => row.group)).toEqual(['staff', 'editors']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('survives an absent map', () => {
|
|
28
|
+
expect(toGroupRows(undefined)).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('fromGroupRows', () => {
|
|
33
|
+
it('turns rows back into a map', () => {
|
|
34
|
+
expect(
|
|
35
|
+
fromGroupRows([{ '@id': 'a', group: 'editors', local: 'site-editors' }]),
|
|
36
|
+
).toEqual({ editors: 'site-editors' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('drops a row the operator has not finished', () => {
|
|
40
|
+
// A blank row is what pressing Add produces, and it must not be stored.
|
|
41
|
+
// It matters more here than in the property map: a half-written row that
|
|
42
|
+
// reached the backend as a key with an empty value is a mapping that
|
|
43
|
+
// grants nothing while looking on screen like one that grants something.
|
|
44
|
+
const blank = { '@id': 'a', group: '', local: '' };
|
|
45
|
+
|
|
46
|
+
expect(fromGroupRows([blank])).toEqual({});
|
|
47
|
+
expect(fromGroupRows([{ ...blank, group: 'editors' }])).toEqual({});
|
|
48
|
+
expect(fromGroupRows([{ ...blank, local: 'site-editors' }])).toEqual({});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('trims both halves', () => {
|
|
52
|
+
const rows = [
|
|
53
|
+
{ '@id': 'a', group: ' editors ', local: ' site-editors ' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
expect(fromGroupRows(rows)).toEqual({ editors: 'site-editors' });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps the last of a repeated provider group', () => {
|
|
60
|
+
// Which is what the object it becomes would do anyway.
|
|
61
|
+
const rows = [
|
|
62
|
+
{ '@id': 'a', group: 'editors', local: 'site-editors' },
|
|
63
|
+
{ '@id': 'b', group: 'editors', local: 'site-staff' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
expect(fromGroupRows(rows)).toEqual({ editors: 'site-staff' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('lets two provider groups grant the same local group', () => {
|
|
70
|
+
const rows = [
|
|
71
|
+
{ '@id': 'a', group: 'editors', local: 'staff' },
|
|
72
|
+
{ '@id': 'b', group: 'authors', local: 'staff' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
expect(fromGroupRows(rows)).toEqual({ editors: 'staff', authors: 'staff' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('survives absent rows', () => {
|
|
79
|
+
expect(fromGroupRows(undefined)).toEqual({});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversion between the stored group map and the rows an editor shows.
|
|
3
|
+
*
|
|
4
|
+
* The mechanics are shared with the property map and live in
|
|
5
|
+
* {@link module:helpers/rowmap}; what belongs here is only the names this
|
|
6
|
+
* mapping's two halves go by.
|
|
7
|
+
*
|
|
8
|
+
* The halves are not symmetric, and the widget reflects that. The provider
|
|
9
|
+
* side is free text, because it is whatever the far end's directory happens
|
|
10
|
+
* to call a group and this site has no way to enumerate it. The local side is
|
|
11
|
+
* a vocabulary, because a group that does not exist here grants nothing --
|
|
12
|
+
* the backend skips it and logs -- and a picker is how that stops being a
|
|
13
|
+
* typo nobody notices.
|
|
14
|
+
* @module helpers/groupmap
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { rowConverters } from './rowmap';
|
|
18
|
+
|
|
19
|
+
/** One row of the group mapping editor. */
|
|
20
|
+
export interface GroupMapRow {
|
|
21
|
+
'@id': string;
|
|
22
|
+
group: string;
|
|
23
|
+
local: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const converters = rowConverters<GroupMapRow>('group', 'local');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Turn a stored group map into editor rows.
|
|
30
|
+
*
|
|
31
|
+
* @param map Provider-side group name to local group id.
|
|
32
|
+
* @returns One row per entry, in the map's own order.
|
|
33
|
+
*/
|
|
34
|
+
export function toGroupRows(
|
|
35
|
+
map: Record<string, string> | undefined,
|
|
36
|
+
): GroupMapRow[] {
|
|
37
|
+
return converters.toRows(map);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Turn editor rows back into a stored group map.
|
|
42
|
+
*
|
|
43
|
+
* @param rows Rows from the editor.
|
|
44
|
+
* @returns Provider-side group name to local group id.
|
|
45
|
+
*/
|
|
46
|
+
export function fromGroupRows(
|
|
47
|
+
rows: GroupMapRow[] | undefined,
|
|
48
|
+
): Record<string, string> {
|
|
49
|
+
return converters.fromRows(rows);
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working out what a user can still add, and how.
|
|
3
|
+
* @module helpers/identities
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The driver whose flow is a mailbox rather than a redirect.
|
|
8
|
+
*
|
|
9
|
+
* The login form renders it as an address field instead of a button, because
|
|
10
|
+
* the thing being proven is control of a mailbox and only the person signing
|
|
11
|
+
* in knows which one.
|
|
12
|
+
*
|
|
13
|
+
* It is *not* offered on the sign-in-methods page. The address a magic link
|
|
14
|
+
* proves is whatever was typed, so a free-text box there verifies any mailbox
|
|
15
|
+
* at all -- and a verified address is what a provider account can be
|
|
16
|
+
* auto-attached to. The addresses this site will verify are the ones already
|
|
17
|
+
* on your profile, and that page offers those instead. The backend enforces
|
|
18
|
+
* the same rule in two places, so this constant is a label rather than a
|
|
19
|
+
* gate: `@identities` leaves the provider out of `available`, and
|
|
20
|
+
* `POST @identities` refuses an address that is not yours.
|
|
21
|
+
*/
|
|
22
|
+
export const EMAIL_DRIVER = 'email';
|