dauth-context-react 6.2.0 → 6.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dauth-context-react",
3
- "version": "6.2.0",
3
+ "version": "6.4.0",
4
4
  "description": "React provider and hook for passwordless authentication via the Dauth service (BFF pattern with httpOnly cookies)",
5
5
  "license": "MIT",
6
6
  "author": "David T. Pizarro Frick",
@@ -299,6 +299,7 @@ export function DauthProfileModal({
299
299
  getPasskeyCredentials,
300
300
  registerPasskey,
301
301
  deletePasskeyCredential,
302
+ uploadAvatar,
302
303
  } = useDauth();
303
304
  const isDesktop = useMediaQuery('(min-width: 641px)');
304
305
  const phase = useModalAnimation(open);
@@ -314,6 +315,12 @@ export function DauthProfileModal({
314
315
  const [lastname, setLastname] = useState('');
315
316
  const [nickname, setNickname] = useState('');
316
317
  const [country, setCountry] = useState('');
318
+ const [telPrefix, setTelPrefix] = useState('');
319
+ const [telSuffix, setTelSuffix] = useState('');
320
+ const [birthDate, setBirthDate] = useState('');
321
+ const [customFieldValues, setCustomFieldValues] = useState<
322
+ Record<string, string>
323
+ >({});
317
324
  const [populated, setPopulated] = useState(false);
318
325
 
319
326
  // Status
@@ -352,6 +359,20 @@ export function DauthProfileModal({
352
359
  setLastname(user.lastname || '');
353
360
  setNickname(user.nickname || '');
354
361
  setCountry(user.country || '');
362
+ setTelPrefix(user.telPrefix || '');
363
+ setTelSuffix(user.telSuffix || '');
364
+ setBirthDate(
365
+ user.birthDate
366
+ ? new Date(user.birthDate)
367
+ .toISOString()
368
+ .split('T')[0]
369
+ : ''
370
+ );
371
+ const cf: Record<string, string> = {};
372
+ for (const f of domain.customFields ?? []) {
373
+ cf[f.key] = user.customFields?.[f.key] ?? '';
374
+ }
375
+ setCustomFieldValues(cf);
355
376
  setPopulated(true);
356
377
  }
357
378
  if (!open) {
@@ -414,13 +435,36 @@ export function DauthProfileModal({
414
435
 
415
436
  const hasChanges = useMemo(() => {
416
437
  if (!user?._id) return false;
438
+ const origBirthDate = user.birthDate
439
+ ? new Date(user.birthDate).toISOString().split('T')[0]
440
+ : '';
441
+ const cfChanged = (domain.customFields ?? []).some(
442
+ (f) =>
443
+ (customFieldValues[f.key] ?? '') !==
444
+ (user.customFields?.[f.key] ?? '')
445
+ );
417
446
  return (
418
447
  name !== (user.name || '') ||
419
448
  lastname !== (user.lastname || '') ||
420
449
  nickname !== (user.nickname || '') ||
421
- country !== (user.country || '')
450
+ country !== (user.country || '') ||
451
+ telPrefix !== (user.telPrefix || '') ||
452
+ telSuffix !== (user.telSuffix || '') ||
453
+ birthDate !== origBirthDate ||
454
+ cfChanged
422
455
  );
423
- }, [name, lastname, nickname, country, user]);
456
+ }, [
457
+ name,
458
+ lastname,
459
+ nickname,
460
+ country,
461
+ telPrefix,
462
+ telSuffix,
463
+ birthDate,
464
+ customFieldValues,
465
+ user,
466
+ domain.customFields,
467
+ ]);
424
468
 
425
469
  const canSave =
426
470
  name.trim().length > 0 && hasChanges && !saving;
@@ -428,10 +472,18 @@ export function DauthProfileModal({
428
472
  const handleSave = useCallback(async () => {
429
473
  setSaving(true);
430
474
  setStatus(null);
431
- const fields: Record<string, string> = { name };
475
+ const fields: Record<string, any> = { name };
432
476
  if (hasField('lastname')) fields.lastname = lastname;
433
477
  if (hasField('nickname')) fields.nickname = nickname;
434
478
  if (hasField('country')) fields.country = country;
479
+ if (hasField('tel_prefix'))
480
+ fields.telPrefix = telPrefix;
481
+ if (hasField('tel_suffix'))
482
+ fields.telSuffix = telSuffix;
483
+ if (hasField('birth_date') && birthDate)
484
+ fields.birthDate = birthDate;
485
+ if ((domain.customFields ?? []).length > 0)
486
+ fields.customFields = customFieldValues;
435
487
  const ok = await updateUser(fields);
436
488
  setSaving(false);
437
489
  if (ok) {
@@ -445,7 +497,19 @@ export function DauthProfileModal({
445
497
  message: 'Something went wrong. Please try again.',
446
498
  });
447
499
  }
448
- }, [name, lastname, nickname, country, hasField, updateUser]);
500
+ }, [
501
+ name,
502
+ lastname,
503
+ nickname,
504
+ country,
505
+ telPrefix,
506
+ telSuffix,
507
+ birthDate,
508
+ customFieldValues,
509
+ hasField,
510
+ updateUser,
511
+ domain.customFields,
512
+ ]);
449
513
 
450
514
  const handleDelete = useCallback(async () => {
451
515
  setDeleting(true);
@@ -507,20 +571,22 @@ export function DauthProfileModal({
507
571
  );
508
572
 
509
573
  const handleAvatarClick = useCallback(() => {
510
- if (onAvatarUpload) {
511
- avatarInputRef.current?.click();
512
- }
513
- }, [onAvatarUpload]);
574
+ avatarInputRef.current?.click();
575
+ }, []);
514
576
 
515
577
  const handleAvatarChange = useCallback(
516
578
  async (e: React.ChangeEvent<HTMLInputElement>) => {
517
579
  const file = e.target.files?.[0];
518
- if (!file || !onAvatarUpload) return;
580
+ if (!file) return;
519
581
  setUploadingAvatar(true);
520
582
  try {
521
- const url = await onAvatarUpload(file);
522
- if (url) {
523
- await updateUser({ avatar: url } as any);
583
+ if (onAvatarUpload) {
584
+ const url = await onAvatarUpload(file);
585
+ if (url) {
586
+ await updateUser({ avatar: url } as any);
587
+ }
588
+ } else {
589
+ await uploadAvatar(file);
524
590
  }
525
591
  } catch {
526
592
  // Error handled by onError callback
@@ -530,7 +596,7 @@ export function DauthProfileModal({
530
596
  avatarInputRef.current.value = '';
531
597
  }
532
598
  },
533
- [onAvatarUpload, updateUser]
599
+ [onAvatarUpload, updateUser, uploadAvatar]
534
600
  );
535
601
 
536
602
  const handleSignOut = useCallback(() => {
@@ -713,9 +779,7 @@ export function DauthProfileModal({
713
779
  <div
714
780
  style={{
715
781
  ...avatarCircle,
716
- cursor: onAvatarUpload
717
- ? 'pointer'
718
- : 'default',
782
+ cursor: 'pointer',
719
783
  position: 'relative' as const,
720
784
  }}
721
785
  onClick={handleAvatarClick}
@@ -735,22 +799,20 @@ export function DauthProfileModal({
735
799
  ) : (
736
800
  avatarInitial
737
801
  )}
738
- {onAvatarUpload && !uploadingAvatar && (
802
+ {!uploadingAvatar && (
739
803
  <div style={avatarOverlay}>
740
804
  <IconCamera />
741
805
  </div>
742
806
  )}
743
807
  </div>
744
808
  <div style={emailText}>{user.email}</div>
745
- {onAvatarUpload && (
746
- <input
747
- ref={avatarInputRef}
748
- type="file"
749
- accept="image/*"
750
- style={{ display: 'none' }}
751
- onChange={handleAvatarChange}
752
- />
753
- )}
809
+ <input
810
+ ref={avatarInputRef}
811
+ type="file"
812
+ accept="image/*"
813
+ style={{ display: 'none' }}
814
+ onChange={handleAvatarChange}
815
+ />
754
816
  </div>
755
817
 
756
818
  {/* Status */}
@@ -862,6 +924,133 @@ export function DauthProfileModal({
862
924
  />
863
925
  </div>
864
926
  )}
927
+
928
+ {(hasField('tel_prefix') ||
929
+ hasField('tel_suffix')) && (
930
+ <div style={fieldGroup}>
931
+ <div style={label}>
932
+ Phone
933
+ {isRequired('tel_prefix') ||
934
+ isRequired('tel_suffix')
935
+ ? ' *'
936
+ : ''}
937
+ </div>
938
+ <div
939
+ style={{
940
+ display: 'flex',
941
+ gap: 8,
942
+ }}
943
+ >
944
+ {hasField('tel_prefix') && (
945
+ <input
946
+ id="dauth-tel-prefix"
947
+ type="text"
948
+ value={telPrefix}
949
+ onChange={(e) =>
950
+ setTelPrefix(e.target.value)
951
+ }
952
+ placeholder="+34"
953
+ disabled={saving}
954
+ style={{
955
+ ...input,
956
+ width: 80,
957
+ flexShrink: 0,
958
+ }}
959
+ onFocus={inputFocusHandler}
960
+ onBlur={inputBlurHandler}
961
+ aria-label="Phone prefix"
962
+ />
963
+ )}
964
+ {hasField('tel_suffix') && (
965
+ <input
966
+ id="dauth-tel-suffix"
967
+ type="tel"
968
+ value={telSuffix}
969
+ onChange={(e) =>
970
+ setTelSuffix(e.target.value)
971
+ }
972
+ placeholder="612 345 678"
973
+ disabled={saving}
974
+ style={{
975
+ ...input,
976
+ flex: 1,
977
+ }}
978
+ onFocus={inputFocusHandler}
979
+ onBlur={inputBlurHandler}
980
+ aria-label="Phone number"
981
+ />
982
+ )}
983
+ </div>
984
+ </div>
985
+ )}
986
+
987
+ {hasField('birth_date') && (
988
+ <div style={fieldGroup}>
989
+ <label
990
+ htmlFor="dauth-birthdate"
991
+ style={label}
992
+ >
993
+ Birth date
994
+ {isRequired('birth_date')
995
+ ? ' *'
996
+ : ''}
997
+ </label>
998
+ <input
999
+ id="dauth-birthdate"
1000
+ type="date"
1001
+ value={birthDate}
1002
+ onChange={(e) =>
1003
+ setBirthDate(e.target.value)
1004
+ }
1005
+ disabled={saving}
1006
+ style={input}
1007
+ onFocus={inputFocusHandler}
1008
+ onBlur={inputBlurHandler}
1009
+ />
1010
+ </div>
1011
+ )}
1012
+
1013
+ {(domain.customFields ?? []).length >
1014
+ 0 && (
1015
+ <>
1016
+ <hr style={separator} />
1017
+ {domain.customFields!.map((cf) => (
1018
+ <div
1019
+ key={cf.key}
1020
+ style={fieldGroup}
1021
+ >
1022
+ <label
1023
+ htmlFor={`dauth-cf-${cf.key}`}
1024
+ style={label}
1025
+ >
1026
+ {cf.label}
1027
+ {cf.required ? ' *' : ''}
1028
+ </label>
1029
+ <input
1030
+ id={`dauth-cf-${cf.key}`}
1031
+ type="text"
1032
+ value={
1033
+ customFieldValues[cf.key] ??
1034
+ ''
1035
+ }
1036
+ onChange={(e) =>
1037
+ setCustomFieldValues(
1038
+ (prev) => ({
1039
+ ...prev,
1040
+ [cf.key]:
1041
+ e.target.value,
1042
+ })
1043
+ )
1044
+ }
1045
+ disabled={saving}
1046
+ style={input}
1047
+ onFocus={inputFocusHandler}
1048
+ onBlur={inputBlurHandler}
1049
+ />
1050
+ </div>
1051
+ ))}
1052
+ </>
1053
+ )}
865
1054
  </div>
866
1055
 
867
1056
  {/* Language selector */}
@@ -8,6 +8,7 @@ import {
8
8
  IPasskeyRegistrationStartResponse,
9
9
  IPasskeyRegistrationFinishResponse,
10
10
  IDeletePasskeyResponse,
11
+ IUploadAvatarResponse,
11
12
  } from './interfaces/dauth.api.responses';
12
13
 
13
14
  function getCsrfToken(): string {
@@ -158,3 +159,19 @@ export async function deletePasskeyCredentialAPI(
158
159
  const data = await response.json();
159
160
  return { response, data };
160
161
  }
162
+
163
+ export async function uploadAvatarAPI(
164
+ basePath: string,
165
+ file: File
166
+ ): Promise<IUploadAvatarResponse> {
167
+ const formData = new FormData();
168
+ formData.append('avatar', file);
169
+ const response = await fetch(`${basePath}/avatar`, {
170
+ method: 'POST',
171
+ headers: { 'X-CSRF-Token': getCsrfToken() },
172
+ credentials: 'include',
173
+ body: formData,
174
+ });
175
+ const data = await response.json();
176
+ return { response, data };
177
+ }
@@ -69,3 +69,12 @@ export interface IDeletePasskeyResponse {
69
69
  message: string;
70
70
  };
71
71
  }
72
+
73
+ export interface IUploadAvatarResponse {
74
+ response: Response;
75
+ data: {
76
+ status: string;
77
+ user: IDauthUser;
78
+ message: string;
79
+ };
80
+ }
package/src/index.tsx CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  IDauthAuthMethods,
18
18
  IDauthUser,
19
19
  IFormField,
20
+ ICustomField,
20
21
  IModalTheme,
21
22
  IPasskeyCredential,
22
23
  DauthProfileModalProps,
@@ -27,6 +28,7 @@ export type {
27
28
  IDauthProviderProps,
28
29
  IDauthAuthMethods,
29
30
  IFormField,
31
+ ICustomField,
30
32
  IModalTheme,
31
33
  IPasskeyCredential,
32
34
  DauthProfileModalProps,
@@ -133,6 +135,11 @@ export const DauthProvider: React.FC<IDauthProviderProps> = (
133
135
  [ctx]
134
136
  );
135
137
 
138
+ const uploadAvatar = useCallback(
139
+ (file: File) => action.uploadAvatarAction(ctx, file),
140
+ [ctx]
141
+ );
142
+
136
143
  const memoProvider = useMemo(
137
144
  () => ({
138
145
  ...dauthState,
@@ -143,6 +150,7 @@ export const DauthProvider: React.FC<IDauthProviderProps> = (
143
150
  getPasskeyCredentials,
144
151
  registerPasskey,
145
152
  deletePasskeyCredential,
153
+ uploadAvatar,
146
154
  }),
147
155
  [
148
156
  dauthState,
@@ -153,6 +161,7 @@ export const DauthProvider: React.FC<IDauthProviderProps> = (
153
161
  getPasskeyCredentials,
154
162
  registerPasskey,
155
163
  deletePasskeyCredential,
164
+ uploadAvatar,
156
165
  ]
157
166
  );
158
167
 
@@ -17,6 +17,7 @@ const initialDauthState: IDauthState = {
17
17
  getPasskeyCredentials: () => Promise.resolve([]),
18
18
  registerPasskey: () => Promise.resolve(null),
19
19
  deletePasskeyCredential: () => Promise.resolve(false),
20
+ uploadAvatar: () => Promise.resolve(false),
20
21
  };
21
22
 
22
23
  export default initialDauthState;
package/src/interfaces.ts CHANGED
@@ -16,6 +16,7 @@ export interface IDauthUser {
16
16
  birthDate?: Date;
17
17
  country?: string;
18
18
  metadata?: Record<string, unknown>;
19
+ customFields?: Record<string, string>;
19
20
  createdAt: Date;
20
21
  updatedAt: Date;
21
22
  lastLogin: Date;
@@ -36,6 +37,12 @@ export interface IFormField {
36
37
  required: boolean;
37
38
  }
38
39
 
40
+ export interface ICustomField {
41
+ key: string;
42
+ label: string;
43
+ required: boolean;
44
+ }
45
+
39
46
  export interface IModalTheme {
40
47
  accent?: string;
41
48
  accentHover?: string;
@@ -53,6 +60,7 @@ export interface IDauthDomainState {
53
60
  allowedOrigins: string[];
54
61
  authMethods?: IDauthAuthMethods;
55
62
  formFields?: IFormField[];
63
+ customFields?: ICustomField[];
56
64
  modalTheme?: IModalTheme;
57
65
  }
58
66
 
@@ -81,14 +89,15 @@ export interface IDauthState {
81
89
  deletePasskeyCredential: (
82
90
  credentialId: string
83
91
  ) => Promise<boolean>;
92
+ uploadAvatar: (file: File) => Promise<boolean>;
84
93
  }
85
94
 
86
95
  export interface DauthProfileModalProps {
87
96
  open: boolean;
88
97
  onClose: () => void;
89
- /** Optional: provide a function to handle avatar upload.
98
+ /** Optional override for avatar upload.
90
99
  * Receives a File, should return the URL string.
91
- * If not provided, the avatar edit button is hidden. */
100
+ * If not provided, uses the built-in upload via the auth proxy. */
92
101
  onAvatarUpload?: (file: File) => Promise<string>;
93
102
  }
94
103
 
@@ -8,6 +8,7 @@ import {
8
8
  startPasskeyRegistrationAPI,
9
9
  finishPasskeyRegistrationAPI,
10
10
  deletePasskeyCredentialAPI,
11
+ uploadAvatarAPI,
11
12
  } from '../api/dauth.api';
12
13
  import type { IPasskeyCredential } from '../api/interfaces/dauth.api.responses';
13
14
  import { createPasskeyCredential } from '../webauthn';
@@ -239,6 +240,36 @@ export async function deletePasskeyCredentialAction(
239
240
  }
240
241
  }
241
242
 
243
+ export async function uploadAvatarAction(
244
+ ctx: ActionContext,
245
+ file: File
246
+ ): Promise<boolean> {
247
+ const { dispatch, authProxyPath, onError } = ctx;
248
+ try {
249
+ const result = await uploadAvatarAPI(authProxyPath, file);
250
+ if (result.response.status === 200) {
251
+ dispatch({
252
+ type: DauthTypes.UPDATE_USER,
253
+ payload: result.data.user,
254
+ });
255
+ return true;
256
+ }
257
+ onError(
258
+ new Error(
259
+ 'Avatar upload error: ' + result.data.message
260
+ )
261
+ );
262
+ return false;
263
+ } catch (error) {
264
+ onError(
265
+ error instanceof Error
266
+ ? error
267
+ : new Error('Avatar upload error')
268
+ );
269
+ return false;
270
+ }
271
+ }
272
+
242
273
  export const resetUser = (dispatch: React.Dispatch<any>) => {
243
274
  return dispatch({
244
275
  type: DauthTypes.LOGIN,