keycloakify 6.12.7-rc.0 → 6.12.8

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.
@@ -1,13 +1,24 @@
1
1
  import React, { useEffect, Fragment } from "react";
2
2
  import type { KcProps } from "../../KcProps";
3
- import type { Attribute } from "../../getKcContext/KcContextBase";
4
3
  import { clsx } from "../../tools/clsx";
5
- import { useCallbackFactory } from "../../tools/useCallbackFactory";
6
- import { useFormValidationSlice } from "../../useFormValidationSlice";
7
4
  import type { I18nBase } from "../../i18n";
5
+ import type { Attribute } from "../../getKcContext";
6
+
7
+ // If you are copy pasting this code in your theme project
8
+ // you can delete all the following import and replace them by
9
+ // import { useFormValidation } from "keycloakify/lib/pages/shared/UserProfileCommons";
10
+ // you can also delete the useFormValidation hooks and useGetErrors hooks, they shouldn't need
11
+ // to be modified.
12
+ import "../../tools/Array.prototype.every";
13
+ import { useMemo, useReducer } from "react";
14
+ import type { KcContextBase, Validators } from "../../getKcContext";
15
+ import { useConstCallback } from "../../tools/useConstCallback";
16
+ import { emailRegexp } from "../../tools/emailRegExp";
17
+ import type { MessageKeyBase } from "../../i18n";
18
+ import { id } from "tsafe/id";
8
19
 
9
20
  export type UserProfileFormFieldsProps = {
10
- kcContext: Parameters<typeof useFormValidationSlice>[0]["kcContext"];
21
+ kcContext: Parameters<typeof useFormValidation>[0]["kcContext"];
11
22
  i18n: I18nBase;
12
23
  } & KcProps &
13
24
  Partial<Record<"BeforeField" | "AfterField", (props: { attribute: Attribute }) => JSX.Element | null>> & {
@@ -26,9 +37,9 @@ export function UserProfileFormFields({
26
37
 
27
38
  const {
28
39
  formValidationState: { fieldStateByAttributeName, isFormSubmittable },
29
- formValidationReducer,
40
+ formValidationDispatch,
30
41
  attributesWithPassword
31
- } = useFormValidationSlice({
42
+ } = useFormValidation({
32
43
  kcContext,
33
44
  i18n
34
45
  });
@@ -37,29 +48,6 @@ export function UserProfileFormFields({
37
48
  onIsFormSubmittableValueChange(isFormSubmittable);
38
49
  }, [isFormSubmittable]);
39
50
 
40
- const onChangeFactory = useCallbackFactory(
41
- (
42
- [name]: [string],
43
- [
44
- {
45
- target: { value }
46
- }
47
- ]: [React.ChangeEvent<HTMLInputElement | HTMLSelectElement>]
48
- ) =>
49
- formValidationReducer({
50
- "action": "update value",
51
- name,
52
- "newValue": value
53
- })
54
- );
55
-
56
- const onBlurFactory = useCallbackFactory(([name]: [string]) =>
57
- formValidationReducer({
58
- "action": "focus lost",
59
- name
60
- })
61
- );
62
-
63
51
  let currentGroup = "";
64
52
 
65
53
  return (
@@ -108,8 +96,19 @@ export function UserProfileFormFields({
108
96
  <select
109
97
  id={attribute.name}
110
98
  name={attribute.name}
111
- onChange={onChangeFactory(attribute.name)}
112
- onBlur={onBlurFactory(attribute.name)}
99
+ onChange={event =>
100
+ formValidationDispatch({
101
+ "action": "update value",
102
+ "name": attribute.name,
103
+ "newValue": event.target.value
104
+ })
105
+ }
106
+ onBlur={() =>
107
+ formValidationDispatch({
108
+ "action": "focus lost",
109
+ "name": attribute.name
110
+ })
111
+ }
113
112
  value={value}
114
113
  >
115
114
  {options.options.map(option => (
@@ -135,12 +134,23 @@ export function UserProfileFormFields({
135
134
  id={attribute.name}
136
135
  name={attribute.name}
137
136
  value={value}
138
- onChange={onChangeFactory(attribute.name)}
137
+ onChange={event =>
138
+ formValidationDispatch({
139
+ "action": "update value",
140
+ "name": attribute.name,
141
+ "newValue": event.target.value
142
+ })
143
+ }
144
+ onBlur={() =>
145
+ formValidationDispatch({
146
+ "action": "focus lost",
147
+ "name": attribute.name
148
+ })
149
+ }
139
150
  className={clsx(props.kcInputClass)}
140
151
  aria-invalid={displayableErrors.length !== 0}
141
152
  disabled={attribute.readOnly}
142
153
  autoComplete={attribute.autocomplete}
143
- onBlur={onBlurFactory(attribute.name)}
144
154
  />
145
155
  );
146
156
  })()}
@@ -166,7 +176,6 @@ export function UserProfileFormFields({
166
176
  })()}
167
177
  </div>
168
178
  </div>
169
-
170
179
  {AfterField && <AfterField attribute={attribute} />}
171
180
  </Fragment>
172
181
  );
@@ -174,3 +183,477 @@ export function UserProfileFormFields({
174
183
  </>
175
184
  );
176
185
  }
186
+
187
+ /**
188
+ * NOTE: The attributesWithPassword returned is actually augmented with
189
+ * artificial password related attributes only if kcContext.passwordRequired === true
190
+ */
191
+ export function useFormValidation(params: {
192
+ kcContext: {
193
+ messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
194
+ profile: {
195
+ attributes: Attribute[];
196
+ };
197
+ passwordRequired?: boolean;
198
+ realm: { registrationEmailAsUsername: boolean };
199
+ };
200
+ /** NOTE: Try to avoid passing a new ref every render for better performances. */
201
+ passwordValidators?: Validators;
202
+ i18n: I18nBase;
203
+ }) {
204
+ const {
205
+ kcContext,
206
+ passwordValidators = {
207
+ "length": {
208
+ "ignore.empty.value": true,
209
+ "min": "4"
210
+ }
211
+ },
212
+ i18n
213
+ } = params;
214
+
215
+ const attributesWithPassword = useMemo(
216
+ () =>
217
+ !kcContext.passwordRequired
218
+ ? kcContext.profile.attributes
219
+ : (() => {
220
+ const name = kcContext.realm.registrationEmailAsUsername ? "email" : "username";
221
+
222
+ return kcContext.profile.attributes.reduce<Attribute[]>(
223
+ (prev, curr) => [
224
+ ...prev,
225
+ ...(curr.name !== name
226
+ ? [curr]
227
+ : [
228
+ curr,
229
+ id<Attribute>({
230
+ "name": "password",
231
+ "displayName": id<`\${${MessageKeyBase}}`>("${password}"),
232
+ "required": true,
233
+ "readOnly": false,
234
+ "validators": passwordValidators,
235
+ "annotations": {},
236
+ "groupAnnotations": {},
237
+ "autocomplete": "new-password"
238
+ }),
239
+ id<Attribute>({
240
+ "name": "password-confirm",
241
+ "displayName": id<`\${${MessageKeyBase}}`>("${passwordConfirm}"),
242
+ "required": true,
243
+ "readOnly": false,
244
+ "validators": {
245
+ "_compareToOther": {
246
+ "name": "password",
247
+ "ignore.empty.value": true,
248
+ "shouldBe": "equal",
249
+ "error-message": id<`\${${MessageKeyBase}}`>("${invalidPasswordConfirmMessage}")
250
+ }
251
+ },
252
+ "annotations": {},
253
+ "groupAnnotations": {},
254
+ "autocomplete": "new-password"
255
+ })
256
+ ])
257
+ ],
258
+ []
259
+ );
260
+ })(),
261
+ [kcContext, passwordValidators]
262
+ );
263
+
264
+ const { getErrors } = useGetErrors({
265
+ "kcContext": {
266
+ "messagesPerField": kcContext.messagesPerField,
267
+ "profile": {
268
+ "attributes": attributesWithPassword
269
+ }
270
+ },
271
+ i18n
272
+ });
273
+
274
+ const initialInternalState = useMemo(
275
+ () =>
276
+ Object.fromEntries(
277
+ attributesWithPassword
278
+ .map(attribute => ({
279
+ attribute,
280
+ "errors": getErrors({
281
+ "name": attribute.name,
282
+ "fieldValueByAttributeName": Object.fromEntries(
283
+ attributesWithPassword.map(({ name, value }) => [name, { "value": value ?? "" }])
284
+ )
285
+ })
286
+ }))
287
+ .map(({ attribute, errors }) => [
288
+ attribute.name,
289
+ {
290
+ "value": attribute.value ?? "",
291
+ errors,
292
+ "doDisplayPotentialErrorMessages": errors.length !== 0
293
+ }
294
+ ])
295
+ ),
296
+ [attributesWithPassword]
297
+ );
298
+
299
+ type InternalState = typeof initialInternalState;
300
+
301
+ const [formValidationInternalState, formValidationDispatch] = useReducer(
302
+ (
303
+ state: InternalState,
304
+ params:
305
+ | {
306
+ action: "update value";
307
+ name: string;
308
+ newValue: string;
309
+ }
310
+ | {
311
+ action: "focus lost";
312
+ name: string;
313
+ }
314
+ ): InternalState => ({
315
+ ...state,
316
+ [params.name]: {
317
+ ...state[params.name],
318
+ ...(() => {
319
+ switch (params.action) {
320
+ case "focus lost":
321
+ return { "doDisplayPotentialErrorMessages": true };
322
+ case "update value":
323
+ return {
324
+ "value": params.newValue,
325
+ "errors": getErrors({
326
+ "name": params.name,
327
+ "fieldValueByAttributeName": {
328
+ ...state,
329
+ [params.name]: { "value": params.newValue }
330
+ }
331
+ })
332
+ };
333
+ }
334
+ })()
335
+ }
336
+ }),
337
+ initialInternalState
338
+ );
339
+
340
+ const formValidationState = useMemo(
341
+ () => ({
342
+ "fieldStateByAttributeName": Object.fromEntries(
343
+ Object.entries(formValidationInternalState).map(([name, { value, errors, doDisplayPotentialErrorMessages }]) => [
344
+ name,
345
+ { value, "displayableErrors": doDisplayPotentialErrorMessages ? errors : [] }
346
+ ])
347
+ ),
348
+ "isFormSubmittable": Object.entries(formValidationInternalState).every(
349
+ ([name, { value, errors }]) =>
350
+ errors.length === 0 && (value !== "" || !attributesWithPassword.find(attribute => attribute.name === name)!.required)
351
+ )
352
+ }),
353
+ [formValidationInternalState, attributesWithPassword]
354
+ );
355
+
356
+ return {
357
+ formValidationState,
358
+ formValidationDispatch,
359
+ attributesWithPassword
360
+ };
361
+ }
362
+
363
+ /** Expect to be used in a component wrapped within a <I18nProvider> */
364
+ function useGetErrors(params: {
365
+ kcContext: {
366
+ messagesPerField: Pick<KcContextBase.Common["messagesPerField"], "existsError" | "get">;
367
+ profile: {
368
+ attributes: { name: string; value?: string; validators: Validators }[];
369
+ };
370
+ };
371
+ i18n: I18nBase;
372
+ }) {
373
+ const { kcContext, i18n } = params;
374
+
375
+ const {
376
+ messagesPerField,
377
+ profile: { attributes }
378
+ } = kcContext;
379
+
380
+ const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n;
381
+
382
+ const getErrors = useConstCallback((params: { name: string; fieldValueByAttributeName: Record<string, { value: string }> }) => {
383
+ const { name, fieldValueByAttributeName } = params;
384
+
385
+ const { value } = fieldValueByAttributeName[name];
386
+
387
+ const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!;
388
+
389
+ block: {
390
+ if (defaultValue !== value) {
391
+ break block;
392
+ }
393
+
394
+ let doesErrorExist: boolean;
395
+
396
+ try {
397
+ doesErrorExist = messagesPerField.existsError(name);
398
+ } catch {
399
+ break block;
400
+ }
401
+
402
+ if (!doesErrorExist) {
403
+ break block;
404
+ }
405
+
406
+ const errorMessageStr = messagesPerField.get(name);
407
+
408
+ return [
409
+ {
410
+ "validatorName": undefined,
411
+ errorMessageStr,
412
+ "errorMessage": <span key={0}>{errorMessageStr}</span>
413
+ }
414
+ ];
415
+ }
416
+
417
+ const errors: {
418
+ errorMessage: JSX.Element;
419
+ errorMessageStr: string;
420
+ validatorName: keyof Validators | undefined;
421
+ }[] = [];
422
+
423
+ scope: {
424
+ const validatorName = "length";
425
+
426
+ const validator = validators[validatorName];
427
+
428
+ if (validator === undefined) {
429
+ break scope;
430
+ }
431
+
432
+ const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator;
433
+
434
+ if (ignoreEmptyValue && value === "") {
435
+ break scope;
436
+ }
437
+
438
+ if (max !== undefined && value.length > parseInt(max)) {
439
+ const msgArgs = ["error-invalid-length-too-long", max] as const;
440
+
441
+ errors.push({
442
+ "errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
443
+ "errorMessageStr": msgStr(...msgArgs),
444
+ validatorName
445
+ });
446
+ }
447
+
448
+ if (min !== undefined && value.length < parseInt(min)) {
449
+ const msgArgs = ["error-invalid-length-too-short", min] as const;
450
+
451
+ errors.push({
452
+ "errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
453
+ "errorMessageStr": msgStr(...msgArgs),
454
+ validatorName
455
+ });
456
+ }
457
+ }
458
+
459
+ scope: {
460
+ const validatorName = "_compareToOther";
461
+
462
+ const validator = validators[validatorName];
463
+
464
+ if (validator === undefined) {
465
+ break scope;
466
+ }
467
+
468
+ const { "ignore.empty.value": ignoreEmptyValue = false, name: otherName, shouldBe, "error-message": errorMessageKey } = validator;
469
+
470
+ if (ignoreEmptyValue && value === "") {
471
+ break scope;
472
+ }
473
+
474
+ const { value: otherValue } = fieldValueByAttributeName[otherName];
475
+
476
+ const isValid = (() => {
477
+ switch (shouldBe) {
478
+ case "different":
479
+ return otherValue !== value;
480
+ case "equal":
481
+ return otherValue === value;
482
+ }
483
+ })();
484
+
485
+ if (isValid) {
486
+ break scope;
487
+ }
488
+
489
+ const msgArg = [
490
+ errorMessageKey ??
491
+ id<MessageKeyBase>(
492
+ (() => {
493
+ switch (shouldBe) {
494
+ case "equal":
495
+ return "shouldBeEqual";
496
+ case "different":
497
+ return "shouldBeDifferent";
498
+ }
499
+ })()
500
+ ),
501
+ otherName,
502
+ name,
503
+ shouldBe
504
+ ] as const;
505
+
506
+ errors.push({
507
+ validatorName,
508
+ "errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArg)}</Fragment>,
509
+ "errorMessageStr": advancedMsgStr(...msgArg)
510
+ });
511
+ }
512
+
513
+ scope: {
514
+ const validatorName = "pattern";
515
+
516
+ const validator = validators[validatorName];
517
+
518
+ if (validator === undefined) {
519
+ break scope;
520
+ }
521
+
522
+ const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator;
523
+
524
+ if (ignoreEmptyValue && value === "") {
525
+ break scope;
526
+ }
527
+
528
+ if (new RegExp(pattern).test(value)) {
529
+ break scope;
530
+ }
531
+
532
+ const msgArgs = [errorMessageKey ?? id<MessageKeyBase>("shouldMatchPattern"), pattern] as const;
533
+
534
+ errors.push({
535
+ validatorName,
536
+ "errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
537
+ "errorMessageStr": advancedMsgStr(...msgArgs)
538
+ });
539
+ }
540
+
541
+ scope: {
542
+ if ([...errors].reverse()[0]?.validatorName === "pattern") {
543
+ break scope;
544
+ }
545
+
546
+ const validatorName = "email";
547
+
548
+ const validator = validators[validatorName];
549
+
550
+ if (validator === undefined) {
551
+ break scope;
552
+ }
553
+
554
+ const { "ignore.empty.value": ignoreEmptyValue = false } = validator;
555
+
556
+ if (ignoreEmptyValue && value === "") {
557
+ break scope;
558
+ }
559
+
560
+ if (emailRegexp.test(value)) {
561
+ break scope;
562
+ }
563
+
564
+ const msgArgs = [id<MessageKeyBase>("invalidEmailMessage")] as const;
565
+
566
+ errors.push({
567
+ validatorName,
568
+ "errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
569
+ "errorMessageStr": msgStr(...msgArgs)
570
+ });
571
+ }
572
+
573
+ scope: {
574
+ const validatorName = "integer";
575
+
576
+ const validator = validators[validatorName];
577
+
578
+ if (validator === undefined) {
579
+ break scope;
580
+ }
581
+
582
+ const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator;
583
+
584
+ if (ignoreEmptyValue && value === "") {
585
+ break scope;
586
+ }
587
+
588
+ const intValue = parseInt(value);
589
+
590
+ if (isNaN(intValue)) {
591
+ const msgArgs = ["mustBeAnInteger"] as const;
592
+
593
+ errors.push({
594
+ validatorName,
595
+ "errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
596
+ "errorMessageStr": msgStr(...msgArgs)
597
+ });
598
+
599
+ break scope;
600
+ }
601
+
602
+ if (max !== undefined && intValue > parseInt(max)) {
603
+ const msgArgs = ["error-number-out-of-range-too-big", max] as const;
604
+
605
+ errors.push({
606
+ validatorName,
607
+ "errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
608
+ "errorMessageStr": msgStr(...msgArgs)
609
+ });
610
+
611
+ break scope;
612
+ }
613
+
614
+ if (min !== undefined && intValue < parseInt(min)) {
615
+ const msgArgs = ["error-number-out-of-range-too-small", min] as const;
616
+
617
+ errors.push({
618
+ validatorName,
619
+ "errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
620
+ "errorMessageStr": msgStr(...msgArgs)
621
+ });
622
+
623
+ break scope;
624
+ }
625
+ }
626
+
627
+ scope: {
628
+ const validatorName = "options";
629
+
630
+ const validator = validators[validatorName];
631
+
632
+ if (validator === undefined) {
633
+ break scope;
634
+ }
635
+
636
+ if (value === "") {
637
+ break scope;
638
+ }
639
+
640
+ if (validator.options.indexOf(value) >= 0) {
641
+ break scope;
642
+ }
643
+
644
+ const msgArgs = [id<MessageKeyBase>("notAValidOption")] as const;
645
+
646
+ errors.push({
647
+ validatorName,
648
+ "errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
649
+ "errorMessageStr": advancedMsgStr(...msgArgs)
650
+ });
651
+ }
652
+
653
+ //TODO: Implement missing validators.
654
+
655
+ return errors;
656
+ });
657
+
658
+ return { getErrors };
659
+ }