@plone-collective/volto-identity 1.0.0-alpha.13 → 1.0.0-alpha.14

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 CHANGED
@@ -1,6 +1,14 @@
1
- ## 1.0.0-alpha.13 (2026-09-30)
1
+ ## 1.0.0-alpha.14 (2026-10-22)
2
2
 
3
- No significant changes.
4
3
 
4
+ ### Feature
5
+
6
+ - Made the local group in a provider's group map a picker over the site's groups, served by the backend. @ericof [#144](https://github.com/collective/pas-plugins-identity/issues/144)
7
+ - Drew the avatar on the toolbar's personal-tools button at 50px instead of 30px, and made the size a CSS variable, `--identity-toolbar-avatar-size`. A site that already sets `--toolbar-avatar-size` keeps its size, and no longer needs its own rule with `!important` to override the avatar's inline width and height. @ericof
8
+
9
+
10
+ ### Internal
11
+
12
+ - Updated code comments that described the property map's target as four fixed fields. @ericof [#143](https://github.com/collective/pas-plugins-identity/issues/143)
5
13
 
6
14
 
package/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@
8
8
 
9
9
  <!-- towncrier release notes start -->
10
10
 
11
+ ## 1.0.0-alpha.14 (2026-10-22)
12
+
13
+
14
+ ### Feature
15
+
16
+ - Made the local group in a provider's group map a picker over the site's groups, served by the backend. @ericof [#144](https://github.com/collective/pas-plugins-identity/issues/144)
17
+ - Drew the avatar on the toolbar's personal-tools button at 50px instead of 30px, and made the size a CSS variable, `--identity-toolbar-avatar-size`. A site that already sets `--toolbar-avatar-size` keeps its size, and no longer needs its own rule with `!important` to override the avatar's inline width and height. @ericof
18
+
19
+
20
+ ### Internal
21
+
22
+ - Updated code comments that described the property map's target as four fixed fields. @ericof [#143](https://github.com/collective/pas-plugins-identity/issues/143)
23
+
11
24
  ## 1.0.0-alpha.13 (2026-09-30)
12
25
 
13
26
  No significant changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plone-collective/volto-identity",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.14",
4
4
  "description": "Volto add-on for multi-provider external authentication in Plone, with identity linking",
5
5
  "main": "src/index.ts",
6
6
  "license": "MIT",
@@ -50,10 +50,10 @@
50
50
  "react-aria-components": "^1.17.0",
51
51
  "react-cookie": "8.1.2",
52
52
  "@storybook/react": "^8.0.4",
53
- "@plone/types": "2.0.1",
54
- "@plone/components": "4.2.1",
55
53
  "@plone/scripts": "4.0.1",
56
- "@plone/registry": "3.0.1"
54
+ "@plone/registry": "3.0.1",
55
+ "@plone/components": "4.2.1",
56
+ "@plone/types": "2.0.1"
57
57
  },
58
58
  "scripts": {
59
59
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -22,7 +22,7 @@ import { providerSchema, toFormData } from '../../helpers/providerSchema';
22
22
  *
23
23
  * Both mappings are `object_list` widgets, and one sub-field of one of them
24
24
  * reads a vocabulary over the API: the property map's target, which is a
25
- * picker over the four Profile fields a login writes. Every story carries the
25
+ * picker over the Profile fields a login writes. Every story carries the
26
26
  * loaded vocabularies in its store, so that picker renders its terms rather
27
27
  * than empty -- which is also exactly how it looks when the backend refuses
28
28
  * `@vocabularies`, and is worth recognising.
@@ -59,7 +59,7 @@ const form = (provider: (typeof CONFIGURED)[number], driverId: string) => ({
59
59
  * The **Attribute mapping** fieldset carries both editors, and the asymmetry
60
60
  * between them is the thing to look at. A claim path and a provider-side
61
61
  * group are text, because neither can be enumerated from here. A property
62
- * map's target is a picker over the four Profile fields a login writes. A
62
+ * map's target is a picker over the Profile fields a login writes. A
63
63
  * local group is text as well, though this site does know its own groups: a
64
64
  * profile may ship a map before the group it points at, and the login skips a
65
65
  * row it cannot resolve rather than the import refusing the map.
@@ -716,7 +716,7 @@ class Toolbar extends Component {
716
716
  tabIndex={0}
717
717
  id="toolbar-personal"
718
718
  >
719
- <UserAvatar size="30px" />
719
+ <UserAvatar size="var(--identity-toolbar-avatar-size)" />
720
720
  </button>
721
721
  )}
722
722
  </div>
@@ -126,4 +126,19 @@ describe('UserAvatar', () => {
126
126
  container.querySelector('.identity-avatar')?.getAttribute('style'),
127
127
  ).toContain('96px');
128
128
  });
129
+
130
+ it('takes a CSS variable as its size, as the toolbar passes one', () => {
131
+ const { container } = renderAvatar(
132
+ { id: 'alice' },
133
+ { size: 'var(--identity-toolbar-avatar-size)' },
134
+ );
135
+
136
+ const style = container
137
+ .querySelector('.identity-avatar')
138
+ ?.getAttribute('style');
139
+ expect(style).toContain('width: var(--identity-toolbar-avatar-size)');
140
+ expect(style).toContain(
141
+ 'font-size: calc(var(--identity-toolbar-avatar-size) / 2.4)',
142
+ );
143
+ });
129
144
  });
@@ -148,8 +148,9 @@ describe('avatarColors', () => {
148
148
  it.each([...DEFAULT_AVATAR_COLORS])(
149
149
  'ships %s, which clears WCAG AA against the white initials',
150
150
  (hex) => {
151
- // 4.5:1, the ratio for normal text: the initials are well under the
152
- // size WCAG counts as large at the 30px the toolbar draws them.
151
+ // 4.5:1, the ratio for normal text, so the palette holds whatever
152
+ // `--identity-toolbar-avatar-size` a site sets: at 30px the initials
153
+ // are well under the size WCAG counts as large.
153
154
  const ratio = (luminance('#ffffff') + 0.05) / (luminance(hex) + 0.05);
154
155
 
155
156
  expect(ratio).toBeGreaterThanOrEqual(4.5);
@@ -5,14 +5,11 @@
5
5
  * {@link module:helpers/rowmap}; what belongs here is only the names this
6
6
  * mapping's two halves go by.
7
7
  *
8
- * Both halves are free text, and only one of them has to be. The provider
9
- * side is whatever the far end's directory happens to call a group, which
10
- * this site cannot enumerate. The local side names a group here, and a row
11
- * naming one that does not exist grants nothing -- the backend skips it and
12
- * logs -- but a profile may legitimately ship a map before the group it
13
- * points at, so the field stays text rather than a `Choice` that would refuse
14
- * the import. Compare the property map, whose target is four fields this
15
- * package knows by name and is a picker.
8
+ * The provider side is free text: it is whatever the far end's directory
9
+ * happens to call a group, which this site cannot enumerate. The local side
10
+ * is a pick from this site's groups, served by the backend, and the API
11
+ * refuses a group that does not exist. Only a profile's import may name one,
12
+ * because it may ship a map before the group it points at.
16
13
  * @module helpers/groupmap
17
14
  */
18
15
 
@@ -20,7 +20,10 @@ import {
20
20
  suggestedProviderId,
21
21
  toFormData,
22
22
  } from './providerSchema';
23
- import { USER_FIELDS_VOCABULARY } from '../constants/vocabularies';
23
+ import {
24
+ GROUPS_VOCABULARY,
25
+ USER_FIELDS_VOCABULARY,
26
+ } from '../constants/vocabularies';
24
27
  import type { Driver, VoltoSchema } from '../types';
25
28
 
26
29
  const intl = {
@@ -28,7 +31,7 @@ const intl = {
28
31
  } as any;
29
32
 
30
33
  /**
31
- * What `@identity-providers` sends: `IProviderRecords`, serialized.
34
+ * What `@identity-providers` sends: `IProviderForm`, serialized.
32
35
  *
33
36
  * Including `driver`, `propertymap` and `groupmap`, which it really does
34
37
  * send -- they are fields of a provider's storage. Leaving them out of this
@@ -61,9 +64,13 @@ const SERVED = {
61
64
  schema: { title: 'Provider group', type: 'string' },
62
65
  additional: {},
63
66
  },
67
+ // The local side is a `Choice` over the site's groups on the served
68
+ // schema, though not on the records it is stored in.
64
69
  value_type: {
65
- schema: { title: 'Local group', type: 'string' },
66
- additional: {},
70
+ schema: { title: 'Local group', type: 'string', factory: 'Choice' },
71
+ additional: {
72
+ vocabulary: { '@id': `/@vocabularies/${GROUPS_VOCABULARY}` },
73
+ },
67
74
  },
68
75
  },
69
76
  icon: { title: 'Icon', type: 'string', widget: 'provider_icon' },
@@ -280,15 +287,23 @@ describe('providerSchema', () => {
280
287
  expect(property.properties.claim).not.toHaveProperty('vocabulary');
281
288
  });
282
289
 
283
- it('leaves both halves of the group map free text', () => {
284
- // The local side names a group that may not exist yet: a profile can
285
- // ship a map before the group it points at, and the login skips a row it
286
- // cannot resolve rather than refusing the map.
290
+ it("makes the group map's local side a picker over the served groups", () => {
291
+ // A typed id with a typo, or a title where the id belongs, saved without
292
+ // complaint and granted nothing at every login.
293
+ const schema = providerSchema(SERVED, DRIVERS, 'oidc', false, intl);
294
+ const group = schema.properties.groupmap?.schema as VoltoSchema;
295
+
296
+ expect((group.properties.local as any).vocabulary['@id']).toBe(
297
+ `/@vocabularies/${GROUPS_VOCABULARY}`,
298
+ );
299
+ });
300
+
301
+ it("leaves the group map's provider side a text box", () => {
302
+ // The far end's own group names, which nothing here enumerates.
287
303
  const schema = providerSchema(SERVED, DRIVERS, 'oidc', false, intl);
288
304
  const group = schema.properties.groupmap?.schema as VoltoSchema;
289
305
 
290
306
  expect(group.properties.group).not.toHaveProperty('vocabulary');
291
- expect(group.properties.local).not.toHaveProperty('vocabulary');
292
307
  });
293
308
 
294
309
  it('falls back to a text box when no vocabulary is served', () => {
@@ -299,14 +314,17 @@ describe('providerSchema', () => {
299
314
  properties: {
300
315
  ...SERVED.properties,
301
316
  propertymap: { title: 'Property map', type: 'object' },
317
+ groupmap: { title: 'Group map', type: 'object' },
302
318
  },
303
319
  };
304
320
 
305
321
  const schema = providerSchema(older, DRIVERS, 'oidc', false, intl);
306
322
  const property = schema.properties.propertymap?.schema as VoltoSchema;
323
+ const group = schema.properties.groupmap?.schema as VoltoSchema;
307
324
 
308
325
  expect(property.properties.field).not.toHaveProperty('vocabulary');
309
326
  expect(property.properties.field.type).toBe('string');
327
+ expect(group.properties.local).not.toHaveProperty('vocabulary');
310
328
  });
311
329
 
312
330
  it('drops the served driver field rather than merging it', () => {
@@ -265,9 +265,9 @@ export function providerSchema(
265
265
  // A picker over the fields a login writes, not a text box. The two
266
266
  // sides of this row are asymmetric for the same reason the group
267
267
  // map's are: a claim path is whatever the far end publishes and
268
- // nothing here can enumerate it, while the target is four fields
269
- // this package knows by name.
270
- vocabulary: targetVocabulary(served),
268
+ // nothing here can enumerate it, while the target is a field of
269
+ // the site's user type, which the backend serves.
270
+ vocabulary: targetVocabulary(served, 'propertymap'),
271
271
  },
272
272
  ),
273
273
  };
@@ -285,7 +285,13 @@ export function providerSchema(
285
285
  schema: rowSchema(
286
286
  intl.formatMessage(messages.groupmapRow),
287
287
  { name: 'group', title: intl.formatMessage(messages.providerGroup) },
288
- { name: 'local', title: intl.formatMessage(messages.localGroup) },
288
+ {
289
+ name: 'local',
290
+ title: intl.formatMessage(messages.localGroup),
291
+ // A pick from this site's groups, for the reason the property
292
+ // map's target is one: a typed id with a typo granted nothing.
293
+ vocabulary: targetVocabulary(served, 'groupmap'),
294
+ },
289
295
  ),
290
296
  };
291
297
  }
@@ -351,21 +357,24 @@ interface VocabularyRef {
351
357
  }
352
358
 
353
359
  /**
354
- * Read the vocabulary the backend serves for a property map's target.
360
+ * Read the vocabulary the backend serves for one mapping's values.
355
361
  *
356
- * `IProviderRecords.propertymap` is a `Dict` whose values are a `Choice` over
357
- * `pas.plugins.identity.UserFields`, and `plone.restapi` serializes a named
358
- * vocabulary as the URL that enumerates it. Lifted from the served schema
359
- * rather than assembled here: the name of the vocabulary, and whether there
360
- * is one at all, are the backend's to say.
362
+ * Both mappings are a `Dict` whose values are a `Choice` on the served
363
+ * schema: the property map's over `pas.plugins.identity.UserFields`, the
364
+ * group map's over `pas.plugins.identity.Groups`. `plone.restapi`
365
+ * serializes a named vocabulary as the URL that enumerates it. Lifted from
366
+ * the served schema rather than assembled here: the name of the vocabulary,
367
+ * and whether there is one at all, are the backend's to say.
361
368
  *
362
369
  * @param served The provider schema from `@identity-providers`.
370
+ * @param mapping Which mapping, `propertymap` or `groupmap`.
363
371
  * @returns The vocabulary reference, or undefined when none is served.
364
372
  */
365
373
  function targetVocabulary(
366
374
  served: JsonSchema | undefined,
375
+ mapping: 'propertymap' | 'groupmap',
367
376
  ): VocabularyRef | undefined {
368
- const value = (served?.properties?.propertymap as any)?.value_type;
377
+ const value = (served?.properties?.[mapping] as any)?.value_type;
369
378
  const reference = value?.additional?.vocabulary;
370
379
  return reference?.['@id'] ? reference : undefined;
371
380
  }
@@ -418,8 +418,12 @@ export const PROVIDER_SCHEMA = {
418
418
  additional: {},
419
419
  },
420
420
  value_type: {
421
- schema: { type: 'string', title: 'Local group' },
422
- additional: {},
421
+ schema: { type: 'string', title: 'Local group', factory: 'Choice' },
422
+ additional: {
423
+ vocabulary: {
424
+ '@id': `http://localhost:8080/Plone/@vocabularies/${GROUPS_VOCABULARY}`,
425
+ },
426
+ },
423
427
  },
424
428
  },
425
429
  title: { type: 'string', title: 'Title' },
@@ -498,10 +502,10 @@ export const CONFIGURED: ConfiguredProvider[] = [
498
502
  /**
499
503
  * The vocabulary the property-map editor reads, already loaded.
500
504
  *
501
- * Four terms, and that is the whole of it: a login writes four Profile
502
- * fields, so those are the four the picker offers. It used to be the site's
503
- * live member schema, which also offered `email`, `portrait` and a username
504
- * -- rows that were stored and then dropped on every login.
505
+ * The four terms of the shipped user type. A site whose type has behaviors
506
+ * gets their fields too. It used to be the site's live member schema, which
507
+ * also offered `email`, `portrait` and a username -- rows that were stored
508
+ * and then dropped on every login.
505
509
  */
506
510
  export const USER_FIELDS_STATE = {
507
511
  [USER_FIELDS_VOCABULARY]: {
package/src/styles.css CHANGED
@@ -89,6 +89,12 @@
89
89
  --identity-provider-oidc-bg-hover: #555;
90
90
  --identity-provider-oidc-color: #fff;
91
91
 
92
+ /* The avatar on the toolbar's personal-tools button, picture and initials
93
+ alike. Passed to `<UserAvatar>` as its size, so it is the inline width
94
+ and height and needs no `!important` to win. `--toolbar-avatar-size` is
95
+ the name sites already set to enlarge it, and it still does. */
96
+ --identity-toolbar-avatar-size: var(--toolbar-avatar-size, 50px);
97
+
92
98
  /* Stats boxes: a figure over what it counts, as on a group page's
93
99
  `42 members · 12 directly`. A box is at least this wide and grows for a
94
100
  long label rather than breaking it -- a translated label is often longer