keycloakify 6.12.7-rc.0 → 6.12.7

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