dauth-context-react 6.1.0 → 6.3.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.
@@ -7,11 +7,15 @@ import React, {
7
7
  } from 'react';
8
8
  import { createPortal } from 'react-dom';
9
9
  import { useDauth } from './index';
10
- import type { DauthProfileModalProps } from './interfaces';
10
+ import type {
11
+ DauthProfileModalProps,
12
+ IPasskeyCredential,
13
+ } from './interfaces';
11
14
 
12
15
  export { type DauthProfileModalProps };
13
16
 
14
17
  type Phase = 'exited' | 'entering' | 'entered' | 'exiting';
18
+ type Tab = 'profile' | 'security' | 'account';
15
19
 
16
20
  const TRANSITION_MS = 200;
17
21
  const MOBILE_TRANSITION_MS = 300;
@@ -56,6 +60,104 @@ function IconBack() {
56
60
  );
57
61
  }
58
62
 
63
+ function IconFingerprint() {
64
+ return (
65
+ <svg
66
+ width="16"
67
+ height="16"
68
+ viewBox="0 0 24 24"
69
+ fill="none"
70
+ stroke="currentColor"
71
+ strokeWidth="2"
72
+ strokeLinecap="round"
73
+ strokeLinejoin="round"
74
+ >
75
+ <path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4" />
76
+ <path d="M14 13.12c0 2.38 0 6.38-1 8.88" />
77
+ <path d="M17.29 21.02c.12-.6.43-2.3.5-3.02" />
78
+ <path d="M2 12a10 10 0 0 1 18-6" />
79
+ <path d="M2 16h.01" />
80
+ <path d="M21.8 16c.2-2 .131-5.354 0-6" />
81
+ <path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2" />
82
+ <path d="M8.65 22c.21-.66.45-1.32.57-2" />
83
+ <path d="M9 6.8a6 6 0 0 1 9 5.2v2" />
84
+ </svg>
85
+ );
86
+ }
87
+
88
+ function IconShield() {
89
+ return (
90
+ <svg
91
+ width="20"
92
+ height="20"
93
+ viewBox="0 0 24 24"
94
+ fill="none"
95
+ stroke="currentColor"
96
+ strokeWidth="2"
97
+ strokeLinecap="round"
98
+ strokeLinejoin="round"
99
+ >
100
+ <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
101
+ </svg>
102
+ );
103
+ }
104
+
105
+ function IconTrash() {
106
+ return (
107
+ <svg
108
+ width="14"
109
+ height="14"
110
+ viewBox="0 0 24 24"
111
+ fill="none"
112
+ stroke="currentColor"
113
+ strokeWidth="2"
114
+ strokeLinecap="round"
115
+ strokeLinejoin="round"
116
+ >
117
+ <path d="M3 6h18" />
118
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
119
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
120
+ </svg>
121
+ );
122
+ }
123
+
124
+ function IconLogOut() {
125
+ return (
126
+ <svg
127
+ width="16"
128
+ height="16"
129
+ viewBox="0 0 24 24"
130
+ fill="none"
131
+ stroke="currentColor"
132
+ strokeWidth="2"
133
+ strokeLinecap="round"
134
+ strokeLinejoin="round"
135
+ >
136
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
137
+ <polyline points="16 17 21 12 16 7" />
138
+ <line x1="21" y1="12" x2="9" y2="12" />
139
+ </svg>
140
+ );
141
+ }
142
+
143
+ function IconCamera() {
144
+ return (
145
+ <svg
146
+ width="14"
147
+ height="14"
148
+ viewBox="0 0 24 24"
149
+ fill="none"
150
+ stroke="currentColor"
151
+ strokeWidth="2"
152
+ strokeLinecap="round"
153
+ strokeLinejoin="round"
154
+ >
155
+ <path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
156
+ <circle cx="12" cy="13" r="3" />
157
+ </svg>
158
+ );
159
+ }
160
+
59
161
  function Spinner() {
60
162
  return <span style={spinnerStyle} aria-hidden="true" />;
61
163
  }
@@ -64,11 +166,14 @@ function Spinner() {
64
166
 
65
167
  function useMediaQuery(query: string): boolean {
66
168
  const [matches, setMatches] = useState(
67
- () => typeof window !== 'undefined' && window.matchMedia(query).matches
169
+ () =>
170
+ typeof window !== 'undefined' &&
171
+ window.matchMedia(query).matches
68
172
  );
69
173
  useEffect(() => {
70
174
  const mq = window.matchMedia(query);
71
- const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
175
+ const handler = (e: MediaQueryListEvent) =>
176
+ setMatches(e.matches);
72
177
  mq.addEventListener('change', handler);
73
178
  return () => mq.removeEventListener('change', handler);
74
179
  }, [query]);
@@ -88,7 +193,10 @@ function useModalAnimation(open: boolean): Phase {
88
193
  }
89
194
  if (phase === 'entered' || phase === 'entering') {
90
195
  setPhase('exiting');
91
- const timer = setTimeout(() => setPhase('exited'), MOBILE_TRANSITION_MS);
196
+ const timer = setTimeout(
197
+ () => setPhase('exited'),
198
+ MOBILE_TRANSITION_MS
199
+ );
92
200
  return () => clearTimeout(timer);
93
201
  }
94
202
  return undefined;
@@ -117,7 +225,6 @@ function useFocusTrap(
117
225
  );
118
226
  (firstInput ?? container).focus();
119
227
  };
120
- // Small delay to let the portal render
121
228
  const raf = requestAnimationFrame(focusFirst);
122
229
 
123
230
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -127,16 +234,20 @@ function useFocusTrap(
127
234
  return;
128
235
  }
129
236
  if (e.key !== 'Tab') return;
130
- const focusable = container.querySelectorAll<HTMLElement>(
131
- 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
132
- );
237
+ const focusable =
238
+ container.querySelectorAll<HTMLElement>(
239
+ 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
240
+ );
133
241
  if (focusable.length === 0) return;
134
242
  const first = focusable[0];
135
243
  const last = focusable[focusable.length - 1];
136
244
  if (e.shiftKey && document.activeElement === first) {
137
245
  e.preventDefault();
138
246
  last.focus();
139
- } else if (!e.shiftKey && document.activeElement === last) {
247
+ } else if (
248
+ !e.shiftKey &&
249
+ document.activeElement === last
250
+ ) {
140
251
  e.preventDefault();
141
252
  first.focus();
142
253
  }
@@ -157,7 +268,8 @@ function useScrollLock(active: boolean) {
157
268
  useEffect(() => {
158
269
  if (!active) return;
159
270
  const scrollbarWidth =
160
- window.innerWidth - document.documentElement.clientWidth;
271
+ window.innerWidth -
272
+ document.documentElement.clientWidth;
161
273
  const prevOverflow = document.body.style.overflow;
162
274
  const prevPaddingRight = document.body.style.paddingRight;
163
275
  document.body.style.overflow = 'hidden';
@@ -173,17 +285,41 @@ function useScrollLock(active: boolean) {
173
285
 
174
286
  // --- Component ---
175
287
 
176
- export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
177
- const { user, domain, updateUser, deleteAccount } = useDauth();
288
+ export function DauthProfileModal({
289
+ open,
290
+ onClose,
291
+ onAvatarUpload,
292
+ }: DauthProfileModalProps) {
293
+ const {
294
+ user,
295
+ domain,
296
+ updateUser,
297
+ deleteAccount,
298
+ logout,
299
+ getPasskeyCredentials,
300
+ registerPasskey,
301
+ deletePasskeyCredential,
302
+ } = useDauth();
178
303
  const isDesktop = useMediaQuery('(min-width: 641px)');
179
304
  const phase = useModalAnimation(open);
180
305
  const modalRef = useRef<HTMLDivElement>(null);
306
+ const avatarInputRef = useRef<HTMLInputElement>(null);
181
307
 
182
- // Form state
308
+ // Tab state
309
+ const showSecurity = domain.authMethods?.passkey === true;
310
+ const [activeTab, setActiveTab] = useState<Tab>('profile');
311
+
312
+ // Profile form state
183
313
  const [name, setName] = useState('');
184
314
  const [lastname, setLastname] = useState('');
185
315
  const [nickname, setNickname] = useState('');
186
316
  const [country, setCountry] = useState('');
317
+ const [telPrefix, setTelPrefix] = useState('');
318
+ const [telSuffix, setTelSuffix] = useState('');
319
+ const [birthDate, setBirthDate] = useState('');
320
+ const [customFieldValues, setCustomFieldValues] = useState<
321
+ Record<string, string>
322
+ >({});
187
323
  const [populated, setPopulated] = useState(false);
188
324
 
189
325
  // Status
@@ -198,6 +334,23 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
198
334
  const [deleteText, setDeleteText] = useState('');
199
335
  const [deleting, setDeleting] = useState(false);
200
336
 
337
+ // Passkey state
338
+ const [credentials, setCredentials] = useState<
339
+ IPasskeyCredential[]
340
+ >([]);
341
+ const [loadingCreds, setLoadingCreds] = useState(false);
342
+ const [showRegister, setShowRegister] = useState(false);
343
+ const [passkeyName, setPasskeyName] = useState('');
344
+ const [registering, setRegistering] = useState(false);
345
+ const [passkeyStatus, setPasskeyStatus] = useState<{
346
+ type: 'success' | 'error';
347
+ message: string;
348
+ } | null>(null);
349
+
350
+ // Avatar upload state
351
+ const [uploadingAvatar, setUploadingAvatar] =
352
+ useState(false);
353
+
201
354
  // Populate form when modal opens
202
355
  useEffect(() => {
203
356
  if (open && user?._id && !populated) {
@@ -205,6 +358,20 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
205
358
  setLastname(user.lastname || '');
206
359
  setNickname(user.nickname || '');
207
360
  setCountry(user.country || '');
361
+ setTelPrefix(user.telPrefix || '');
362
+ setTelSuffix(user.telSuffix || '');
363
+ setBirthDate(
364
+ user.birthDate
365
+ ? new Date(user.birthDate)
366
+ .toISOString()
367
+ .split('T')[0]
368
+ : ''
369
+ );
370
+ const cf: Record<string, string> = {};
371
+ for (const f of domain.customFields ?? []) {
372
+ cf[f.key] = user.customFields?.[f.key] ?? '';
373
+ }
374
+ setCustomFieldValues(cf);
208
375
  setPopulated(true);
209
376
  }
210
377
  if (!open) {
@@ -212,50 +379,110 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
212
379
  setStatus(null);
213
380
  setShowDelete(false);
214
381
  setDeleteText('');
382
+ setActiveTab('profile');
383
+ setPasskeyStatus(null);
384
+ setShowRegister(false);
385
+ setPasskeyName('');
215
386
  }
216
387
  }, [open, user, populated]);
217
388
 
389
+ // Fetch passkey credentials when Security tab is active
390
+ useEffect(() => {
391
+ if (activeTab !== 'security' || !showSecurity) return;
392
+ setLoadingCreds(true);
393
+ getPasskeyCredentials().then((creds) => {
394
+ setCredentials(creds);
395
+ setLoadingCreds(false);
396
+ });
397
+ }, [activeTab, showSecurity, getPasskeyCredentials]);
398
+
218
399
  // Auto-clear success message
219
400
  useEffect(() => {
220
401
  if (status?.type !== 'success') return;
221
- const timer = setTimeout(() => setStatus(null), SUCCESS_TIMEOUT_MS);
402
+ const timer = setTimeout(
403
+ () => setStatus(null),
404
+ SUCCESS_TIMEOUT_MS
405
+ );
222
406
  return () => clearTimeout(timer);
223
407
  }, [status]);
224
408
 
409
+ useEffect(() => {
410
+ if (passkeyStatus?.type !== 'success') return;
411
+ const timer = setTimeout(
412
+ () => setPasskeyStatus(null),
413
+ SUCCESS_TIMEOUT_MS
414
+ );
415
+ return () => clearTimeout(timer);
416
+ }, [passkeyStatus]);
417
+
225
418
  useFocusTrap(modalRef, phase === 'entered', onClose);
226
419
  useScrollLock(phase !== 'exited');
227
420
 
228
421
  const hasField = useCallback(
229
422
  (field: string) =>
230
- domain.formFields?.some((f) => f.field === field) ?? false,
423
+ domain.formFields?.some((f) => f.field === field) ??
424
+ false,
231
425
  [domain.formFields]
232
426
  );
233
427
 
234
428
  const isRequired = useCallback(
235
429
  (field: string) =>
236
- domain.formFields?.find((f) => f.field === field)?.required ?? false,
430
+ domain.formFields?.find((f) => f.field === field)
431
+ ?.required ?? false,
237
432
  [domain.formFields]
238
433
  );
239
434
 
240
435
  const hasChanges = useMemo(() => {
241
436
  if (!user?._id) return false;
437
+ const origBirthDate = user.birthDate
438
+ ? new Date(user.birthDate).toISOString().split('T')[0]
439
+ : '';
440
+ const cfChanged = (domain.customFields ?? []).some(
441
+ (f) =>
442
+ (customFieldValues[f.key] ?? '') !==
443
+ (user.customFields?.[f.key] ?? '')
444
+ );
242
445
  return (
243
446
  name !== (user.name || '') ||
244
447
  lastname !== (user.lastname || '') ||
245
448
  nickname !== (user.nickname || '') ||
246
- country !== (user.country || '')
449
+ country !== (user.country || '') ||
450
+ telPrefix !== (user.telPrefix || '') ||
451
+ telSuffix !== (user.telSuffix || '') ||
452
+ birthDate !== origBirthDate ||
453
+ cfChanged
247
454
  );
248
- }, [name, lastname, nickname, country, user]);
249
-
250
- const canSave = name.trim().length > 0 && hasChanges && !saving;
455
+ }, [
456
+ name,
457
+ lastname,
458
+ nickname,
459
+ country,
460
+ telPrefix,
461
+ telSuffix,
462
+ birthDate,
463
+ customFieldValues,
464
+ user,
465
+ domain.customFields,
466
+ ]);
467
+
468
+ const canSave =
469
+ name.trim().length > 0 && hasChanges && !saving;
251
470
 
252
471
  const handleSave = useCallback(async () => {
253
472
  setSaving(true);
254
473
  setStatus(null);
255
- const fields: Record<string, string> = { name };
474
+ const fields: Record<string, any> = { name };
256
475
  if (hasField('lastname')) fields.lastname = lastname;
257
476
  if (hasField('nickname')) fields.nickname = nickname;
258
477
  if (hasField('country')) fields.country = country;
478
+ if (hasField('tel_prefix'))
479
+ fields.telPrefix = telPrefix;
480
+ if (hasField('tel_suffix'))
481
+ fields.telSuffix = telSuffix;
482
+ if (hasField('birth_date') && birthDate)
483
+ fields.birthDate = birthDate;
484
+ if ((domain.customFields ?? []).length > 0)
485
+ fields.customFields = customFieldValues;
259
486
  const ok = await updateUser(fields);
260
487
  setSaving(false);
261
488
  if (ok) {
@@ -269,7 +496,19 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
269
496
  message: 'Something went wrong. Please try again.',
270
497
  });
271
498
  }
272
- }, [name, lastname, nickname, country, hasField, updateUser]);
499
+ }, [
500
+ name,
501
+ lastname,
502
+ nickname,
503
+ country,
504
+ telPrefix,
505
+ telSuffix,
506
+ birthDate,
507
+ customFieldValues,
508
+ hasField,
509
+ updateUser,
510
+ domain.customFields,
511
+ ]);
273
512
 
274
513
  const handleDelete = useCallback(async () => {
275
514
  setDeleting(true);
@@ -280,24 +519,103 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
280
519
  } else {
281
520
  setStatus({
282
521
  type: 'error',
283
- message: 'Could not delete account. Please try again.',
522
+ message:
523
+ 'Could not delete account. Please try again.',
284
524
  });
285
525
  setShowDelete(false);
286
526
  setDeleteText('');
287
527
  }
288
528
  }, [deleteAccount, onClose]);
289
529
 
530
+ const handleLanguage = useCallback(
531
+ async (lang: string) => {
532
+ await updateUser({ language: lang } as any);
533
+ },
534
+ [updateUser]
535
+ );
536
+
537
+ const handleRegisterPasskey = useCallback(async () => {
538
+ setRegistering(true);
539
+ setPasskeyStatus(null);
540
+ const cred = await registerPasskey(
541
+ passkeyName || undefined
542
+ );
543
+ setRegistering(false);
544
+ if (cred) {
545
+ setCredentials((prev) => [...prev, cred]);
546
+ setPasskeyName('');
547
+ setShowRegister(false);
548
+ setPasskeyStatus({
549
+ type: 'success',
550
+ message: 'Passkey registered successfully',
551
+ });
552
+ } else {
553
+ setPasskeyStatus({
554
+ type: 'error',
555
+ message: 'Failed to register passkey',
556
+ });
557
+ }
558
+ }, [passkeyName, registerPasskey]);
559
+
560
+ const handleDeletePasskey = useCallback(
561
+ async (credentialId: string) => {
562
+ const ok = await deletePasskeyCredential(credentialId);
563
+ if (ok) {
564
+ setCredentials((prev) =>
565
+ prev.filter((c) => c._id !== credentialId)
566
+ );
567
+ }
568
+ },
569
+ [deletePasskeyCredential]
570
+ );
571
+
572
+ const handleAvatarClick = useCallback(() => {
573
+ if (onAvatarUpload) {
574
+ avatarInputRef.current?.click();
575
+ }
576
+ }, [onAvatarUpload]);
577
+
578
+ const handleAvatarChange = useCallback(
579
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
580
+ const file = e.target.files?.[0];
581
+ if (!file || !onAvatarUpload) return;
582
+ setUploadingAvatar(true);
583
+ try {
584
+ const url = await onAvatarUpload(file);
585
+ if (url) {
586
+ await updateUser({ avatar: url } as any);
587
+ }
588
+ } catch {
589
+ // Error handled by onError callback
590
+ }
591
+ setUploadingAvatar(false);
592
+ if (avatarInputRef.current) {
593
+ avatarInputRef.current.value = '';
594
+ }
595
+ },
596
+ [onAvatarUpload, updateUser]
597
+ );
598
+
599
+ const handleSignOut = useCallback(() => {
600
+ logout();
601
+ onClose();
602
+ }, [logout, onClose]);
603
+
290
604
  // Build CSS custom property overrides from domain.modalTheme
291
605
  const themeVars = useMemo(() => {
292
606
  const t = domain.modalTheme;
293
607
  if (!t) return {};
294
608
  const vars: Record<string, string> = {};
295
609
  if (t.accent) vars['--dauth-accent'] = t.accent;
296
- if (t.accentHover) vars['--dauth-accent-hover'] = t.accentHover;
610
+ if (t.accentHover)
611
+ vars['--dauth-accent-hover'] = t.accentHover;
297
612
  if (t.surface) vars['--dauth-surface'] = t.surface;
298
- if (t.surfaceHover) vars['--dauth-surface-hover'] = t.surfaceHover;
299
- if (t.textPrimary) vars['--dauth-text-primary'] = t.textPrimary;
300
- if (t.textSecondary) vars['--dauth-text-secondary'] = t.textSecondary;
613
+ if (t.surfaceHover)
614
+ vars['--dauth-surface-hover'] = t.surfaceHover;
615
+ if (t.textPrimary)
616
+ vars['--dauth-text-primary'] = t.textPrimary;
617
+ if (t.textSecondary)
618
+ vars['--dauth-text-secondary'] = t.textSecondary;
301
619
  if (t.border) vars['--dauth-border'] = t.border;
302
620
  return vars;
303
621
  }, [domain.modalTheme]);
@@ -311,7 +629,8 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
311
629
  position: 'fixed',
312
630
  inset: 0,
313
631
  zIndex: 2147483647,
314
- backgroundColor: 'var(--dauth-backdrop, rgba(0, 0, 0, 0.6))',
632
+ backgroundColor:
633
+ 'var(--dauth-backdrop, rgba(0, 0, 0, 0.6))',
315
634
  backdropFilter: 'blur(4px)',
316
635
  WebkitBackdropFilter: 'blur(4px)',
317
636
  display: 'flex',
@@ -329,8 +648,10 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
329
648
  margin: 16,
330
649
  backgroundColor: 'var(--dauth-surface, #1a1a2e)',
331
650
  borderRadius: 'var(--dauth-radius, 12px)',
332
- boxShadow: 'var(--dauth-shadow, 0 25px 50px -12px rgba(0, 0, 0, 0.5))',
333
- border: '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
651
+ boxShadow:
652
+ 'var(--dauth-shadow, 0 25px 50px -12px rgba(0, 0, 0, 0.5))',
653
+ border:
654
+ '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
334
655
  display: 'flex',
335
656
  flexDirection: 'column',
336
657
  overflow: 'hidden',
@@ -338,7 +659,10 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
338
659
  'var(--dauth-font-family, system-ui, -apple-system, sans-serif)',
339
660
  color: 'var(--dauth-text-primary, #e4e4e7)',
340
661
  opacity: phase === 'entered' ? 1 : 0,
341
- transform: phase === 'entered' ? 'translateY(0)' : 'translateY(16px)',
662
+ transform:
663
+ phase === 'entered'
664
+ ? 'translateY(0)'
665
+ : 'translateY(16px)',
342
666
  transition: `opacity ${dur}ms ${easing}, transform ${dur}ms ${easing}`,
343
667
  };
344
668
 
@@ -351,7 +675,10 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
351
675
  fontFamily:
352
676
  'var(--dauth-font-family, system-ui, -apple-system, sans-serif)',
353
677
  color: 'var(--dauth-text-primary, #e4e4e7)',
354
- transform: phase === 'entered' ? 'translateY(0)' : 'translateY(100%)',
678
+ transform:
679
+ phase === 'entered'
680
+ ? 'translateY(0)'
681
+ : 'translateY(100%)',
355
682
  transition: `transform ${dur}ms ${easing}`,
356
683
  };
357
684
 
@@ -359,11 +686,20 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
359
686
  .charAt(0)
360
687
  .toUpperCase();
361
688
 
689
+ const tabs: { key: Tab; label: string }[] = [
690
+ { key: 'profile', label: 'Profile' },
691
+ ...(showSecurity
692
+ ? [{ key: 'security' as Tab, label: 'Security' }]
693
+ : []),
694
+ { key: 'account', label: 'Account' },
695
+ ];
696
+
362
697
  return createPortal(
363
698
  <>
364
699
  <style
365
700
  dangerouslySetInnerHTML={{
366
- __html: '@keyframes dauth-spin{to{transform:rotate(360deg)}}',
701
+ __html:
702
+ '@keyframes dauth-spin{to{transform:rotate(360deg)}}',
367
703
  }}
368
704
  />
369
705
  <div
@@ -392,7 +728,8 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
392
728
  'var(--dauth-surface-hover, #232340)')
393
729
  }
394
730
  onMouseLeave={(e) =>
395
- (e.currentTarget.style.backgroundColor = 'transparent')
731
+ (e.currentTarget.style.backgroundColor =
732
+ 'transparent')
396
733
  }
397
734
  >
398
735
  {isDesktop ? <IconClose /> : <IconBack />}
@@ -403,231 +740,770 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
403
740
  <div style={{ width: 36 }} />
404
741
  </div>
405
742
 
743
+ {/* Tab bar */}
744
+ <div style={tabBar} role="tablist">
745
+ {tabs.map((t) => (
746
+ <button
747
+ key={t.key}
748
+ role="tab"
749
+ type="button"
750
+ aria-selected={activeTab === t.key}
751
+ style={{
752
+ ...tabBtn,
753
+ color:
754
+ activeTab === t.key
755
+ ? 'var(--dauth-accent, #6366f1)'
756
+ : 'var(--dauth-text-secondary, #a1a1aa)',
757
+ borderBottomColor:
758
+ activeTab === t.key
759
+ ? 'var(--dauth-accent, #6366f1)'
760
+ : 'transparent',
761
+ }}
762
+ onClick={() => setActiveTab(t.key)}
763
+ >
764
+ {t.label}
765
+ </button>
766
+ ))}
767
+ </div>
768
+
406
769
  {/* Scrollable body */}
407
770
  <div style={bodyStyle}>
408
- {/* Avatar */}
409
- <div style={avatarSection}>
410
- <div style={avatarCircle}>
411
- {user.avatar?.url ? (
412
- <img
413
- src={user.avatar.url}
414
- alt=""
771
+ {/* ========== PROFILE TAB ========== */}
772
+ {activeTab === 'profile' && (
773
+ <>
774
+ {/* Avatar */}
775
+ <div style={avatarSection}>
776
+ <div
415
777
  style={{
416
- width: '100%',
417
- height: '100%',
418
- objectFit: 'cover',
778
+ ...avatarCircle,
779
+ cursor: onAvatarUpload
780
+ ? 'pointer'
781
+ : 'default',
782
+ position: 'relative' as const,
419
783
  }}
420
- />
421
- ) : (
422
- avatarInitial
784
+ onClick={handleAvatarClick}
785
+ >
786
+ {uploadingAvatar ? (
787
+ <Spinner />
788
+ ) : user.avatar?.url ? (
789
+ <img
790
+ src={user.avatar.url}
791
+ alt=""
792
+ style={{
793
+ width: '100%',
794
+ height: '100%',
795
+ objectFit: 'cover',
796
+ }}
797
+ />
798
+ ) : (
799
+ avatarInitial
800
+ )}
801
+ {onAvatarUpload && !uploadingAvatar && (
802
+ <div style={avatarOverlay}>
803
+ <IconCamera />
804
+ </div>
805
+ )}
806
+ </div>
807
+ <div style={emailText}>{user.email}</div>
808
+ {onAvatarUpload && (
809
+ <input
810
+ ref={avatarInputRef}
811
+ type="file"
812
+ accept="image/*"
813
+ style={{ display: 'none' }}
814
+ onChange={handleAvatarChange}
815
+ />
816
+ )}
817
+ </div>
818
+
819
+ {/* Status */}
820
+ {status && (
821
+ <div
822
+ role="status"
823
+ aria-live="polite"
824
+ style={statusMsg(status.type)}
825
+ >
826
+ {status.message}
827
+ </div>
423
828
  )}
424
- </div>
425
- <div style={emailText}>{user.email}</div>
426
- </div>
427
829
 
428
- {/* Status */}
429
- {status && (
430
- <div
431
- role="status"
432
- aria-live="polite"
433
- style={statusMsg(status.type)}
434
- >
435
- {status.message}
436
- </div>
437
- )}
830
+ {/* Form */}
831
+ <div>
832
+ <div style={fieldGroup}>
833
+ <label
834
+ htmlFor="dauth-name"
835
+ style={label}
836
+ >
837
+ Name *
838
+ </label>
839
+ <input
840
+ id="dauth-name"
841
+ type="text"
842
+ value={name}
843
+ onChange={(e) =>
844
+ setName(e.target.value)
845
+ }
846
+ placeholder="Your name"
847
+ disabled={saving}
848
+ style={input}
849
+ onFocus={inputFocusHandler}
850
+ onBlur={inputBlurHandler}
851
+ />
852
+ </div>
438
853
 
439
- {/* Form */}
440
- <div>
441
- <div style={fieldGroup}>
442
- <label htmlFor="dauth-name" style={label}>
443
- Name *
444
- </label>
445
- <input
446
- id="dauth-name"
447
- type="text"
448
- value={name}
449
- onChange={(e) => setName(e.target.value)}
450
- placeholder="Your name"
451
- disabled={saving}
452
- style={input}
453
- onFocus={inputFocusHandler}
454
- onBlur={inputBlurHandler}
455
- />
456
- </div>
457
-
458
- {hasField('lastname') && (
459
- <div style={fieldGroup}>
460
- <label htmlFor="dauth-lastname" style={label}>
461
- Last name
462
- {isRequired('lastname') ? ' *' : ''}
463
- </label>
464
- <input
465
- id="dauth-lastname"
466
- type="text"
467
- value={lastname}
468
- onChange={(e) => setLastname(e.target.value)}
469
- placeholder="Your last name"
470
- disabled={saving}
471
- style={input}
472
- onFocus={inputFocusHandler}
473
- onBlur={inputBlurHandler}
474
- />
854
+ {hasField('lastname') && (
855
+ <div style={fieldGroup}>
856
+ <label
857
+ htmlFor="dauth-lastname"
858
+ style={label}
859
+ >
860
+ Last name
861
+ {isRequired('lastname') ? ' *' : ''}
862
+ </label>
863
+ <input
864
+ id="dauth-lastname"
865
+ type="text"
866
+ value={lastname}
867
+ onChange={(e) =>
868
+ setLastname(e.target.value)
869
+ }
870
+ placeholder="Your last name"
871
+ disabled={saving}
872
+ style={input}
873
+ onFocus={inputFocusHandler}
874
+ onBlur={inputBlurHandler}
875
+ />
876
+ </div>
877
+ )}
878
+
879
+ {hasField('nickname') && (
880
+ <div style={fieldGroup}>
881
+ <label
882
+ htmlFor="dauth-nickname"
883
+ style={label}
884
+ >
885
+ Nickname
886
+ {isRequired('nickname') ? ' *' : ''}
887
+ </label>
888
+ <input
889
+ id="dauth-nickname"
890
+ type="text"
891
+ value={nickname}
892
+ onChange={(e) =>
893
+ setNickname(e.target.value)
894
+ }
895
+ placeholder="Choose a nickname"
896
+ disabled={saving}
897
+ style={input}
898
+ onFocus={inputFocusHandler}
899
+ onBlur={inputBlurHandler}
900
+ />
901
+ </div>
902
+ )}
903
+
904
+ {hasField('country') && (
905
+ <div style={fieldGroup}>
906
+ <label
907
+ htmlFor="dauth-country"
908
+ style={label}
909
+ >
910
+ Country
911
+ {isRequired('country') ? ' *' : ''}
912
+ </label>
913
+ <input
914
+ id="dauth-country"
915
+ type="text"
916
+ value={country}
917
+ onChange={(e) =>
918
+ setCountry(e.target.value)
919
+ }
920
+ placeholder="Your country"
921
+ disabled={saving}
922
+ style={input}
923
+ onFocus={inputFocusHandler}
924
+ onBlur={inputBlurHandler}
925
+ />
926
+ </div>
927
+ )}
928
+
929
+ {(hasField('tel_prefix') ||
930
+ hasField('tel_suffix')) && (
931
+ <div style={fieldGroup}>
932
+ <div style={label}>
933
+ Phone
934
+ {isRequired('tel_prefix') ||
935
+ isRequired('tel_suffix')
936
+ ? ' *'
937
+ : ''}
938
+ </div>
939
+ <div
940
+ style={{
941
+ display: 'flex',
942
+ gap: 8,
943
+ }}
944
+ >
945
+ {hasField('tel_prefix') && (
946
+ <input
947
+ id="dauth-tel-prefix"
948
+ type="text"
949
+ value={telPrefix}
950
+ onChange={(e) =>
951
+ setTelPrefix(e.target.value)
952
+ }
953
+ placeholder="+34"
954
+ disabled={saving}
955
+ style={{
956
+ ...input,
957
+ width: 80,
958
+ flexShrink: 0,
959
+ }}
960
+ onFocus={inputFocusHandler}
961
+ onBlur={inputBlurHandler}
962
+ aria-label="Phone prefix"
963
+ />
964
+ )}
965
+ {hasField('tel_suffix') && (
966
+ <input
967
+ id="dauth-tel-suffix"
968
+ type="tel"
969
+ value={telSuffix}
970
+ onChange={(e) =>
971
+ setTelSuffix(e.target.value)
972
+ }
973
+ placeholder="612 345 678"
974
+ disabled={saving}
975
+ style={{
976
+ ...input,
977
+ flex: 1,
978
+ }}
979
+ onFocus={inputFocusHandler}
980
+ onBlur={inputBlurHandler}
981
+ aria-label="Phone number"
982
+ />
983
+ )}
984
+ </div>
985
+ </div>
986
+ )}
987
+
988
+ {hasField('birth_date') && (
989
+ <div style={fieldGroup}>
990
+ <label
991
+ htmlFor="dauth-birthdate"
992
+ style={label}
993
+ >
994
+ Birth date
995
+ {isRequired('birth_date')
996
+ ? ' *'
997
+ : ''}
998
+ </label>
999
+ <input
1000
+ id="dauth-birthdate"
1001
+ type="date"
1002
+ value={birthDate}
1003
+ onChange={(e) =>
1004
+ setBirthDate(e.target.value)
1005
+ }
1006
+ disabled={saving}
1007
+ style={input}
1008
+ onFocus={inputFocusHandler}
1009
+ onBlur={inputBlurHandler}
1010
+ />
1011
+ </div>
1012
+ )}
1013
+
1014
+ {(domain.customFields ?? []).length >
1015
+ 0 && (
1016
+ <>
1017
+ <hr style={separator} />
1018
+ {domain.customFields!.map((cf) => (
1019
+ <div
1020
+ key={cf.key}
1021
+ style={fieldGroup}
1022
+ >
1023
+ <label
1024
+ htmlFor={`dauth-cf-${cf.key}`}
1025
+ style={label}
1026
+ >
1027
+ {cf.label}
1028
+ {cf.required ? ' *' : ''}
1029
+ </label>
1030
+ <input
1031
+ id={`dauth-cf-${cf.key}`}
1032
+ type="text"
1033
+ value={
1034
+ customFieldValues[cf.key] ??
1035
+ ''
1036
+ }
1037
+ onChange={(e) =>
1038
+ setCustomFieldValues(
1039
+ (prev) => ({
1040
+ ...prev,
1041
+ [cf.key]:
1042
+ e.target.value,
1043
+ })
1044
+ )
1045
+ }
1046
+ disabled={saving}
1047
+ style={input}
1048
+ onFocus={inputFocusHandler}
1049
+ onBlur={inputBlurHandler}
1050
+ />
1051
+ </div>
1052
+ ))}
1053
+ </>
1054
+ )}
475
1055
  </div>
476
- )}
477
1056
 
478
- {hasField('nickname') && (
1057
+ {/* Language selector */}
1058
+ <hr style={separator} />
479
1059
  <div style={fieldGroup}>
480
- <label htmlFor="dauth-nickname" style={label}>
481
- Nickname
482
- {isRequired('nickname') ? ' *' : ''}
483
- </label>
484
- <input
485
- id="dauth-nickname"
486
- type="text"
487
- value={nickname}
488
- onChange={(e) => setNickname(e.target.value)}
489
- placeholder="Choose a nickname"
490
- disabled={saving}
491
- style={input}
492
- onFocus={inputFocusHandler}
493
- onBlur={inputBlurHandler}
494
- />
1060
+ <div style={label}>Language</div>
1061
+ <div style={{ display: 'flex', gap: 8 }}>
1062
+ {(['es', 'en'] as const).map((lang) => (
1063
+ <button
1064
+ key={lang}
1065
+ type="button"
1066
+ style={{
1067
+ ...langBtn,
1068
+ backgroundColor:
1069
+ user.language === lang
1070
+ ? 'var(--dauth-accent, #6366f1)'
1071
+ : 'var(--dauth-surface-secondary, rgba(255, 255, 255, 0.04))',
1072
+ color:
1073
+ user.language === lang
1074
+ ? '#ffffff'
1075
+ : 'var(--dauth-text-secondary, #a1a1aa)',
1076
+ }}
1077
+ onClick={() => handleLanguage(lang)}
1078
+ >
1079
+ {lang === 'es'
1080
+ ? 'Espa\u00f1ol'
1081
+ : 'English'}
1082
+ </button>
1083
+ ))}
1084
+ </div>
495
1085
  </div>
496
- )}
1086
+ </>
1087
+ )}
497
1088
 
498
- {hasField('country') && (
499
- <div style={fieldGroup}>
500
- <label htmlFor="dauth-country" style={label}>
501
- Country
502
- {isRequired('country') ? ' *' : ''}
503
- </label>
504
- <input
505
- id="dauth-country"
506
- type="text"
507
- value={country}
508
- onChange={(e) => setCountry(e.target.value)}
509
- placeholder="Your country"
510
- disabled={saving}
511
- style={input}
512
- onFocus={inputFocusHandler}
513
- onBlur={inputBlurHandler}
514
- />
1089
+ {/* ========== SECURITY TAB ========== */}
1090
+ {activeTab === 'security' && showSecurity && (
1091
+ <>
1092
+ <div
1093
+ style={{
1094
+ display: 'flex',
1095
+ alignItems: 'center',
1096
+ justifyContent: 'space-between',
1097
+ marginBottom: 16,
1098
+ }}
1099
+ >
1100
+ <div
1101
+ style={{
1102
+ ...label,
1103
+ marginBottom: 0,
1104
+ fontWeight: 600,
1105
+ }}
1106
+ >
1107
+ Passkeys
1108
+ </div>
1109
+ <button
1110
+ type="button"
1111
+ style={outlineBtn}
1112
+ onClick={() =>
1113
+ setShowRegister(!showRegister)
1114
+ }
1115
+ onMouseEnter={(e) =>
1116
+ (e.currentTarget.style.backgroundColor =
1117
+ 'var(--dauth-surface-hover, #232340)')
1118
+ }
1119
+ onMouseLeave={(e) =>
1120
+ (e.currentTarget.style.backgroundColor =
1121
+ 'transparent')
1122
+ }
1123
+ >
1124
+ + Add passkey
1125
+ </button>
515
1126
  </div>
516
- )}
517
- </div>
518
1127
 
519
- {/* Danger zone */}
520
- <hr style={separator} />
521
- <div>
522
- <div style={dangerTitle}>Delete account</div>
523
- <div style={dangerDesc}>
524
- Permanently delete your account and all associated data.
525
- </div>
526
- {!showDelete ? (
527
- <button
528
- type="button"
529
- style={deleteBtn}
530
- onClick={() => setShowDelete(true)}
531
- onMouseEnter={(e) =>
532
- (e.currentTarget.style.backgroundColor =
533
- 'rgba(239, 68, 68, 0.2)')
534
- }
535
- onMouseLeave={(e) =>
536
- (e.currentTarget.style.backgroundColor =
537
- 'var(--dauth-error-bg, rgba(239, 68, 68, 0.1))')
538
- }
539
- >
540
- Delete account
541
- </button>
542
- ) : (
543
- <div style={deletePanel}>
544
- <div style={deletePanelText}>
545
- This action is permanent and cannot be undone. Type{' '}
546
- <strong>{CONFIRM_WORD}</strong> to confirm.
1128
+ {/* Register passkey form */}
1129
+ {showRegister && (
1130
+ <div style={registerPanel}>
1131
+ <div style={fieldGroup}>
1132
+ <label
1133
+ htmlFor="dauth-passkey-name"
1134
+ style={label}
1135
+ >
1136
+ Passkey name (optional)
1137
+ </label>
1138
+ <input
1139
+ id="dauth-passkey-name"
1140
+ type="text"
1141
+ value={passkeyName}
1142
+ onChange={(e) =>
1143
+ setPasskeyName(e.target.value)
1144
+ }
1145
+ placeholder="e.g. MacBook Touch ID"
1146
+ disabled={registering}
1147
+ style={input}
1148
+ onFocus={inputFocusHandler}
1149
+ onBlur={inputBlurHandler}
1150
+ />
1151
+ </div>
1152
+ <div
1153
+ style={{
1154
+ display: 'flex',
1155
+ gap: 8,
1156
+ }}
1157
+ >
1158
+ <button
1159
+ type="button"
1160
+ style={{
1161
+ ...smallAccentBtn,
1162
+ opacity: registering ? 0.6 : 1,
1163
+ }}
1164
+ disabled={registering}
1165
+ onClick={handleRegisterPasskey}
1166
+ >
1167
+ {registering ? (
1168
+ <Spinner />
1169
+ ) : (
1170
+ <IconFingerprint />
1171
+ )}
1172
+ {registering
1173
+ ? 'Registering...'
1174
+ : 'Register'}
1175
+ </button>
1176
+ <button
1177
+ type="button"
1178
+ style={cancelBtn}
1179
+ onClick={() =>
1180
+ setShowRegister(false)
1181
+ }
1182
+ onMouseEnter={(e) =>
1183
+ (e.currentTarget.style.backgroundColor =
1184
+ 'var(--dauth-surface-hover, #232340)')
1185
+ }
1186
+ onMouseLeave={(e) =>
1187
+ (e.currentTarget.style.backgroundColor =
1188
+ 'transparent')
1189
+ }
1190
+ >
1191
+ Cancel
1192
+ </button>
1193
+ </div>
547
1194
  </div>
548
- <input
549
- type="text"
550
- value={deleteText}
551
- onChange={(e) => setDeleteText(e.target.value)}
552
- placeholder={`Type ${CONFIRM_WORD}`}
553
- style={input}
554
- onFocus={inputFocusHandler}
555
- onBlur={inputBlurHandler}
556
- disabled={deleting}
557
- />
1195
+ )}
1196
+
1197
+ {/* Passkey status */}
1198
+ {passkeyStatus && (
558
1199
  <div
1200
+ role="status"
1201
+ aria-live="polite"
559
1202
  style={{
560
- display: 'flex',
561
- gap: 8,
1203
+ ...statusMsg(passkeyStatus.type),
562
1204
  marginTop: 12,
563
1205
  }}
564
1206
  >
1207
+ {passkeyStatus.message}
1208
+ </div>
1209
+ )}
1210
+
1211
+ {/* Credentials list */}
1212
+ <div style={{ marginTop: 12 }}>
1213
+ {loadingCreds ? (
1214
+ <div
1215
+ style={{
1216
+ textAlign: 'center',
1217
+ padding: 24,
1218
+ }}
1219
+ >
1220
+ <Spinner />
1221
+ </div>
1222
+ ) : credentials.length > 0 ? (
1223
+ credentials.map((cred) => (
1224
+ <div
1225
+ key={cred._id}
1226
+ style={credentialRow}
1227
+ >
1228
+ <div
1229
+ style={{
1230
+ display: 'flex',
1231
+ alignItems: 'center',
1232
+ gap: 12,
1233
+ flex: 1,
1234
+ minWidth: 0,
1235
+ }}
1236
+ >
1237
+ <span
1238
+ style={{
1239
+ color:
1240
+ 'var(--dauth-accent, #6366f1)',
1241
+ flexShrink: 0,
1242
+ }}
1243
+ >
1244
+ <IconFingerprint />
1245
+ </span>
1246
+ <div
1247
+ style={{
1248
+ minWidth: 0,
1249
+ flex: 1,
1250
+ }}
1251
+ >
1252
+ <div
1253
+ style={{
1254
+ fontSize:
1255
+ 'var(--dauth-font-size-sm, 0.875rem)',
1256
+ fontWeight: 500,
1257
+ color:
1258
+ 'var(--dauth-text-primary, #e4e4e7)',
1259
+ overflow: 'hidden',
1260
+ textOverflow: 'ellipsis',
1261
+ whiteSpace:
1262
+ 'nowrap' as const,
1263
+ }}
1264
+ >
1265
+ {cred.name || 'Passkey'}
1266
+ </div>
1267
+ <div
1268
+ style={{
1269
+ fontSize:
1270
+ 'var(--dauth-font-size-xs, 0.75rem)',
1271
+ color:
1272
+ 'var(--dauth-text-muted, #71717a)',
1273
+ }}
1274
+ >
1275
+ {cred.deviceType ===
1276
+ 'multiDevice'
1277
+ ? 'Synced'
1278
+ : 'Device-bound'}
1279
+ {cred.createdAt &&
1280
+ ` \u00b7 Created ${new Date(cred.createdAt).toLocaleDateString()}`}
1281
+ </div>
1282
+ </div>
1283
+ </div>
1284
+ <button
1285
+ type="button"
1286
+ onClick={() =>
1287
+ handleDeletePasskey(cred._id)
1288
+ }
1289
+ style={trashBtn}
1290
+ onMouseEnter={(e) =>
1291
+ (e.currentTarget.style.color =
1292
+ 'var(--dauth-error, #ef4444)')
1293
+ }
1294
+ onMouseLeave={(e) =>
1295
+ (e.currentTarget.style.color =
1296
+ 'var(--dauth-text-muted, #71717a)')
1297
+ }
1298
+ aria-label={`Delete passkey ${cred.name || ''}`}
1299
+ >
1300
+ <IconTrash />
1301
+ </button>
1302
+ </div>
1303
+ ))
1304
+ ) : (
1305
+ <div style={emptyState}>
1306
+ <span
1307
+ style={{
1308
+ color:
1309
+ 'var(--dauth-accent, #6366f1)',
1310
+ }}
1311
+ >
1312
+ <IconShield />
1313
+ </span>
1314
+ <div>
1315
+ <div
1316
+ style={{
1317
+ fontSize:
1318
+ 'var(--dauth-font-size-sm, 0.875rem)',
1319
+ fontWeight: 500,
1320
+ color:
1321
+ 'var(--dauth-text-primary, #e4e4e7)',
1322
+ }}
1323
+ >
1324
+ No passkeys registered
1325
+ </div>
1326
+ <div
1327
+ style={{
1328
+ fontSize:
1329
+ 'var(--dauth-font-size-xs, 0.75rem)',
1330
+ color:
1331
+ 'var(--dauth-text-secondary, #a1a1aa)',
1332
+ }}
1333
+ >
1334
+ Add a passkey for faster, more
1335
+ secure sign-in.
1336
+ </div>
1337
+ </div>
1338
+ </div>
1339
+ )}
1340
+ </div>
1341
+ </>
1342
+ )}
1343
+
1344
+ {/* ========== ACCOUNT TAB ========== */}
1345
+ {activeTab === 'account' && (
1346
+ <>
1347
+ {/* Status (shared) */}
1348
+ {status && (
1349
+ <div
1350
+ role="status"
1351
+ aria-live="polite"
1352
+ style={statusMsg(status.type)}
1353
+ >
1354
+ {status.message}
1355
+ </div>
1356
+ )}
1357
+
1358
+ {/* Delete account */}
1359
+ <div>
1360
+ <div style={dangerTitle}>
1361
+ Delete account
1362
+ </div>
1363
+ <div style={dangerDesc}>
1364
+ Permanently delete your account and all
1365
+ associated data.
1366
+ </div>
1367
+ {!showDelete ? (
565
1368
  <button
566
1369
  type="button"
567
- style={cancelBtn}
568
- onClick={() => {
569
- setShowDelete(false);
570
- setDeleteText('');
571
- }}
1370
+ style={deleteBtn}
1371
+ onClick={() => setShowDelete(true)}
572
1372
  onMouseEnter={(e) =>
573
1373
  (e.currentTarget.style.backgroundColor =
574
- 'var(--dauth-surface-hover, #232340)')
1374
+ 'rgba(239, 68, 68, 0.2)')
575
1375
  }
576
1376
  onMouseLeave={(e) =>
577
- (e.currentTarget.style.backgroundColor = 'transparent')
1377
+ (e.currentTarget.style.backgroundColor =
1378
+ 'var(--dauth-error-bg, rgba(239, 68, 68, 0.1))')
578
1379
  }
579
1380
  >
580
- Cancel
581
- </button>
582
- <button
583
- type="button"
584
- style={{
585
- ...deleteConfirmBtn,
586
- opacity:
587
- deleteText !== CONFIRM_WORD || deleting ? 0.5 : 1,
588
- cursor:
589
- deleteText !== CONFIRM_WORD || deleting
590
- ? 'not-allowed'
591
- : 'pointer',
592
- }}
593
- disabled={deleteText !== CONFIRM_WORD || deleting}
594
- onClick={handleDelete}
595
- >
596
- {deleting && <Spinner />}
597
- Delete my account
1381
+ Delete account
598
1382
  </button>
599
- </div>
1383
+ ) : (
1384
+ <div style={deletePanel}>
1385
+ <div style={deletePanelText}>
1386
+ This action is permanent and cannot
1387
+ be undone. Type{' '}
1388
+ <strong>{CONFIRM_WORD}</strong> to
1389
+ confirm.
1390
+ </div>
1391
+ <input
1392
+ type="text"
1393
+ value={deleteText}
1394
+ onChange={(e) =>
1395
+ setDeleteText(e.target.value)
1396
+ }
1397
+ placeholder={`Type ${CONFIRM_WORD}`}
1398
+ style={input}
1399
+ onFocus={inputFocusHandler}
1400
+ onBlur={inputBlurHandler}
1401
+ disabled={deleting}
1402
+ />
1403
+ <div
1404
+ style={{
1405
+ display: 'flex',
1406
+ gap: 8,
1407
+ marginTop: 12,
1408
+ }}
1409
+ >
1410
+ <button
1411
+ type="button"
1412
+ style={cancelBtn}
1413
+ onClick={() => {
1414
+ setShowDelete(false);
1415
+ setDeleteText('');
1416
+ }}
1417
+ onMouseEnter={(e) =>
1418
+ (e.currentTarget.style.backgroundColor =
1419
+ 'var(--dauth-surface-hover, #232340)')
1420
+ }
1421
+ onMouseLeave={(e) =>
1422
+ (e.currentTarget.style.backgroundColor =
1423
+ 'transparent')
1424
+ }
1425
+ >
1426
+ Cancel
1427
+ </button>
1428
+ <button
1429
+ type="button"
1430
+ style={{
1431
+ ...deleteConfirmBtn,
1432
+ opacity:
1433
+ deleteText !== CONFIRM_WORD ||
1434
+ deleting
1435
+ ? 0.5
1436
+ : 1,
1437
+ cursor:
1438
+ deleteText !== CONFIRM_WORD ||
1439
+ deleting
1440
+ ? 'not-allowed'
1441
+ : 'pointer',
1442
+ }}
1443
+ disabled={
1444
+ deleteText !== CONFIRM_WORD ||
1445
+ deleting
1446
+ }
1447
+ onClick={handleDelete}
1448
+ >
1449
+ {deleting && <Spinner />}
1450
+ Delete my account
1451
+ </button>
1452
+ </div>
1453
+ </div>
1454
+ )}
600
1455
  </div>
601
- )}
602
- </div>
1456
+
1457
+ {/* Sign out */}
1458
+ <hr style={separator} />
1459
+ <button
1460
+ type="button"
1461
+ style={signOutBtn}
1462
+ onClick={handleSignOut}
1463
+ onMouseEnter={(e) =>
1464
+ (e.currentTarget.style.backgroundColor =
1465
+ 'rgba(239, 68, 68, 0.2)')
1466
+ }
1467
+ onMouseLeave={(e) =>
1468
+ (e.currentTarget.style.backgroundColor =
1469
+ 'var(--dauth-error-bg, rgba(239, 68, 68, 0.1))')
1470
+ }
1471
+ >
1472
+ <IconLogOut />
1473
+ Sign out
1474
+ </button>
1475
+ </>
1476
+ )}
603
1477
  </div>
604
1478
 
605
- {/* Footer */}
606
- <div style={footerStyle(isDesktop)}>
607
- <button
608
- type="button"
609
- style={{
610
- ...saveBtn,
611
- opacity: canSave ? 1 : 0.5,
612
- cursor: canSave ? 'pointer' : 'not-allowed',
613
- }}
614
- disabled={!canSave}
615
- onClick={handleSave}
616
- aria-busy={saving}
617
- onMouseEnter={(e) => {
618
- if (canSave)
1479
+ {/* Footer — only for Profile tab */}
1480
+ {activeTab === 'profile' && (
1481
+ <div style={footerStyle(isDesktop)}>
1482
+ <button
1483
+ type="button"
1484
+ style={{
1485
+ ...saveBtn,
1486
+ opacity: canSave ? 1 : 0.5,
1487
+ cursor: canSave ? 'pointer' : 'not-allowed',
1488
+ }}
1489
+ disabled={!canSave}
1490
+ onClick={handleSave}
1491
+ aria-busy={saving}
1492
+ onMouseEnter={(e) => {
1493
+ if (canSave)
1494
+ e.currentTarget.style.backgroundColor =
1495
+ 'var(--dauth-accent-hover, #818cf8)';
1496
+ }}
1497
+ onMouseLeave={(e) => {
619
1498
  e.currentTarget.style.backgroundColor =
620
- 'var(--dauth-accent-hover, #818cf8)';
621
- }}
622
- onMouseLeave={(e) => {
623
- e.currentTarget.style.backgroundColor =
624
- 'var(--dauth-accent, #6366f1)';
625
- }}
626
- >
627
- {saving && <Spinner />}
628
- {saving ? 'Saving...' : 'Save changes'}
629
- </button>
630
- </div>
1499
+ 'var(--dauth-accent, #6366f1)';
1500
+ }}
1501
+ >
1502
+ {saving && <Spinner />}
1503
+ {saving ? 'Saving...' : 'Save changes'}
1504
+ </button>
1505
+ </div>
1506
+ )}
631
1507
  </div>
632
1508
  </div>
633
1509
  </>,
@@ -637,19 +1513,21 @@ export function DauthProfileModal({ open, onClose }: DauthProfileModalProps) {
637
1513
 
638
1514
  // --- Style constants ---
639
1515
 
640
- const headerStyle = (isDesktop: boolean): React.CSSProperties => ({
1516
+ const headerStyle = (
1517
+ isDesktop: boolean
1518
+ ): React.CSSProperties => ({
641
1519
  display: 'flex',
642
1520
  alignItems: 'center',
643
1521
  justifyContent: 'space-between',
644
- padding: '16px 24px',
645
- borderBottom: '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
1522
+ padding: '16px 24px 0',
646
1523
  flexShrink: 0,
647
1524
  ...(!isDesktop
648
1525
  ? {
649
1526
  position: 'sticky' as const,
650
1527
  top: 0,
651
1528
  zIndex: 1,
652
- backgroundColor: 'var(--dauth-surface, #1a1a2e)',
1529
+ backgroundColor:
1530
+ 'var(--dauth-surface, #1a1a2e)',
653
1531
  }
654
1532
  : {}),
655
1533
  });
@@ -678,6 +1556,28 @@ const closeBtn: React.CSSProperties = {
678
1556
  padding: 0,
679
1557
  };
680
1558
 
1559
+ const tabBar: React.CSSProperties = {
1560
+ display: 'flex',
1561
+ padding: '0 24px',
1562
+ borderBottom:
1563
+ '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
1564
+ flexShrink: 0,
1565
+ };
1566
+
1567
+ const tabBtn: React.CSSProperties = {
1568
+ flex: 1,
1569
+ padding: '12px 4px',
1570
+ fontSize: 'var(--dauth-font-size-sm, 0.875rem)',
1571
+ fontWeight: 500,
1572
+ border: 'none',
1573
+ borderBottom: '2px solid transparent',
1574
+ backgroundColor: 'transparent',
1575
+ cursor: 'pointer',
1576
+ transition: 'color 150ms, border-color 150ms',
1577
+ fontFamily: 'inherit',
1578
+ textAlign: 'center',
1579
+ };
1580
+
681
1581
  const bodyStyle: React.CSSProperties = {
682
1582
  flex: 1,
683
1583
  overflowY: 'auto',
@@ -707,12 +1607,27 @@ const avatarCircle: React.CSSProperties = {
707
1607
  fontWeight: 600,
708
1608
  };
709
1609
 
1610
+ const avatarOverlay: React.CSSProperties = {
1611
+ position: 'absolute',
1612
+ inset: 0,
1613
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
1614
+ display: 'flex',
1615
+ alignItems: 'center',
1616
+ justifyContent: 'center',
1617
+ borderRadius: '50%',
1618
+ opacity: 0.7,
1619
+ transition: 'opacity 150ms',
1620
+ color: '#ffffff',
1621
+ };
1622
+
710
1623
  const emailText: React.CSSProperties = {
711
1624
  fontSize: 'var(--dauth-font-size-sm, 0.875rem)',
712
1625
  color: 'var(--dauth-text-secondary, #a1a1aa)',
713
1626
  };
714
1627
 
715
- const statusMsg = (type: 'success' | 'error'): React.CSSProperties => ({
1628
+ const statusMsg = (
1629
+ type: 'success' | 'error'
1630
+ ): React.CSSProperties => ({
716
1631
  padding: '10px 14px',
717
1632
  borderRadius: 'var(--dauth-radius-sm, 8px)',
718
1633
  fontSize: 'var(--dauth-font-size-sm, 0.875rem)',
@@ -729,7 +1644,9 @@ const statusMsg = (type: 'success' | 'error'): React.CSSProperties => ({
729
1644
  lineHeight: 1.5,
730
1645
  });
731
1646
 
732
- const fieldGroup: React.CSSProperties = { marginBottom: 16 };
1647
+ const fieldGroup: React.CSSProperties = {
1648
+ marginBottom: 16,
1649
+ };
733
1650
 
734
1651
  const label: React.CSSProperties = {
735
1652
  display: 'block',
@@ -745,8 +1662,10 @@ const input: React.CSSProperties = {
745
1662
  fontSize: 'var(--dauth-font-size-base, 1rem)',
746
1663
  lineHeight: 1.5,
747
1664
  color: 'var(--dauth-text-primary, #e4e4e7)',
748
- backgroundColor: 'var(--dauth-surface-secondary, rgba(255, 255, 255, 0.04))',
749
- border: '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
1665
+ backgroundColor:
1666
+ 'var(--dauth-surface-secondary, rgba(255, 255, 255, 0.04))',
1667
+ border:
1668
+ '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
750
1669
  borderRadius: 'var(--dauth-radius-input, 8px)',
751
1670
  outline: 'none',
752
1671
  transition: 'border-color 150ms, box-shadow 150ms',
@@ -754,25 +1673,119 @@ const input: React.CSSProperties = {
754
1673
  fontFamily: 'inherit',
755
1674
  };
756
1675
 
757
- const inputFocusHandler = (e: React.FocusEvent<HTMLInputElement>) => {
1676
+ const inputFocusHandler = (
1677
+ e: React.FocusEvent<HTMLInputElement>
1678
+ ) => {
758
1679
  e.currentTarget.style.borderColor =
759
1680
  'var(--dauth-border-focus, rgba(99, 102, 241, 0.5))';
760
- e.currentTarget.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.15)';
1681
+ e.currentTarget.style.boxShadow =
1682
+ '0 0 0 3px rgba(99, 102, 241, 0.15)';
761
1683
  };
762
1684
 
763
- const inputBlurHandler = (e: React.FocusEvent<HTMLInputElement>) => {
1685
+ const inputBlurHandler = (
1686
+ e: React.FocusEvent<HTMLInputElement>
1687
+ ) => {
764
1688
  e.currentTarget.style.borderColor =
765
1689
  'var(--dauth-border, rgba(255, 255, 255, 0.08))';
766
1690
  e.currentTarget.style.boxShadow = 'none';
767
1691
  };
768
1692
 
1693
+ const langBtn: React.CSSProperties = {
1694
+ padding: '8px 16px',
1695
+ fontSize: 'var(--dauth-font-size-sm, 0.875rem)',
1696
+ fontWeight: 500,
1697
+ border: 'none',
1698
+ borderRadius: 'var(--dauth-radius-sm, 8px)',
1699
+ cursor: 'pointer',
1700
+ transition: 'background-color 150ms, color 150ms',
1701
+ fontFamily: 'inherit',
1702
+ };
1703
+
769
1704
  const separator: React.CSSProperties = {
770
1705
  height: 1,
771
- backgroundColor: 'var(--dauth-border, rgba(255, 255, 255, 0.08))',
1706
+ backgroundColor:
1707
+ 'var(--dauth-border, rgba(255, 255, 255, 0.08))',
772
1708
  margin: '24px 0',
773
1709
  border: 'none',
774
1710
  };
775
1711
 
1712
+ const outlineBtn: React.CSSProperties = {
1713
+ padding: '6px 12px',
1714
+ fontSize: 'var(--dauth-font-size-xs, 0.75rem)',
1715
+ fontWeight: 500,
1716
+ color: 'var(--dauth-text-secondary, #a1a1aa)',
1717
+ backgroundColor: 'transparent',
1718
+ border:
1719
+ '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
1720
+ borderRadius: 'var(--dauth-radius-sm, 8px)',
1721
+ cursor: 'pointer',
1722
+ transition: 'background-color 150ms',
1723
+ fontFamily: 'inherit',
1724
+ };
1725
+
1726
+ const registerPanel: React.CSSProperties = {
1727
+ padding: 16,
1728
+ borderRadius: 'var(--dauth-radius-sm, 8px)',
1729
+ border:
1730
+ '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
1731
+ backgroundColor:
1732
+ 'var(--dauth-surface-secondary, rgba(255, 255, 255, 0.04))',
1733
+ marginBottom: 12,
1734
+ };
1735
+
1736
+ const smallAccentBtn: React.CSSProperties = {
1737
+ padding: '8px 16px',
1738
+ fontSize: 'var(--dauth-font-size-sm, 0.875rem)',
1739
+ fontWeight: 500,
1740
+ color: '#ffffff',
1741
+ backgroundColor: 'var(--dauth-accent, #6366f1)',
1742
+ border: 'none',
1743
+ borderRadius: 'var(--dauth-radius-sm, 8px)',
1744
+ cursor: 'pointer',
1745
+ transition: 'opacity 150ms',
1746
+ fontFamily: 'inherit',
1747
+ display: 'flex',
1748
+ alignItems: 'center',
1749
+ gap: 6,
1750
+ };
1751
+
1752
+ const credentialRow: React.CSSProperties = {
1753
+ display: 'flex',
1754
+ alignItems: 'center',
1755
+ justifyContent: 'space-between',
1756
+ padding: 12,
1757
+ borderRadius: 'var(--dauth-radius-sm, 8px)',
1758
+ backgroundColor:
1759
+ 'var(--dauth-surface-secondary, rgba(255, 255, 255, 0.04))',
1760
+ marginBottom: 8,
1761
+ };
1762
+
1763
+ const trashBtn: React.CSSProperties = {
1764
+ display: 'flex',
1765
+ alignItems: 'center',
1766
+ justifyContent: 'center',
1767
+ width: 28,
1768
+ height: 28,
1769
+ border: 'none',
1770
+ backgroundColor: 'transparent',
1771
+ color: 'var(--dauth-text-muted, #71717a)',
1772
+ cursor: 'pointer',
1773
+ transition: 'color 150ms',
1774
+ padding: 0,
1775
+ borderRadius: 'var(--dauth-radius-sm, 8px)',
1776
+ flexShrink: 0,
1777
+ };
1778
+
1779
+ const emptyState: React.CSSProperties = {
1780
+ display: 'flex',
1781
+ alignItems: 'center',
1782
+ gap: 12,
1783
+ padding: 16,
1784
+ borderRadius: 'var(--dauth-radius-sm, 8px)',
1785
+ backgroundColor:
1786
+ 'var(--dauth-surface-secondary, rgba(255, 255, 255, 0.04))',
1787
+ };
1788
+
776
1789
  const dangerTitle: React.CSSProperties = {
777
1790
  fontSize: 'var(--dauth-font-size-sm, 0.875rem)',
778
1791
  fontWeight: 600,
@@ -792,7 +1805,8 @@ const deleteBtn: React.CSSProperties = {
792
1805
  fontSize: 'var(--dauth-font-size-sm, 0.875rem)',
793
1806
  fontWeight: 500,
794
1807
  color: 'var(--dauth-error, #ef4444)',
795
- backgroundColor: 'var(--dauth-error-bg, rgba(239, 68, 68, 0.1))',
1808
+ backgroundColor:
1809
+ 'var(--dauth-error-bg, rgba(239, 68, 68, 0.1))',
796
1810
  border: '1px solid rgba(239, 68, 68, 0.2)',
797
1811
  borderRadius: 'var(--dauth-radius-sm, 8px)',
798
1812
  cursor: 'pointer',
@@ -805,7 +1819,8 @@ const deletePanel: React.CSSProperties = {
805
1819
  padding: 16,
806
1820
  borderRadius: 'var(--dauth-radius-sm, 8px)',
807
1821
  border: '1px solid rgba(239, 68, 68, 0.3)',
808
- backgroundColor: 'var(--dauth-error-bg, rgba(239, 68, 68, 0.1))',
1822
+ backgroundColor:
1823
+ 'var(--dauth-error-bg, rgba(239, 68, 68, 0.1))',
809
1824
  };
810
1825
 
811
1826
  const deletePanelText: React.CSSProperties = {
@@ -816,13 +1831,13 @@ const deletePanelText: React.CSSProperties = {
816
1831
  };
817
1832
 
818
1833
  const cancelBtn: React.CSSProperties = {
819
- flex: 1,
820
1834
  padding: '8px 16px',
821
1835
  fontSize: 'var(--dauth-font-size-sm, 0.875rem)',
822
1836
  fontWeight: 500,
823
1837
  color: 'var(--dauth-text-secondary, #a1a1aa)',
824
1838
  backgroundColor: 'transparent',
825
- border: '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
1839
+ border:
1840
+ '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
826
1841
  borderRadius: 'var(--dauth-radius-sm, 8px)',
827
1842
  cursor: 'pointer',
828
1843
  transition: 'background-color 150ms',
@@ -847,17 +1862,41 @@ const deleteConfirmBtn: React.CSSProperties = {
847
1862
  gap: 8,
848
1863
  };
849
1864
 
850
- const footerStyle = (isDesktop: boolean): React.CSSProperties => ({
1865
+ const signOutBtn: React.CSSProperties = {
1866
+ width: '100%',
1867
+ padding: '12px 24px',
1868
+ fontSize: 'var(--dauth-font-size-base, 1rem)',
1869
+ fontWeight: 500,
1870
+ color: 'var(--dauth-error, #ef4444)',
1871
+ backgroundColor:
1872
+ 'var(--dauth-error-bg, rgba(239, 68, 68, 0.1))',
1873
+ border: '1px solid rgba(239, 68, 68, 0.2)',
1874
+ borderRadius: 'var(--dauth-radius-sm, 8px)',
1875
+ cursor: 'pointer',
1876
+ transition: 'background-color 150ms',
1877
+ fontFamily: 'inherit',
1878
+ display: 'flex',
1879
+ alignItems: 'center',
1880
+ justifyContent: 'center',
1881
+ gap: 8,
1882
+ };
1883
+
1884
+ const footerStyle = (
1885
+ isDesktop: boolean
1886
+ ): React.CSSProperties => ({
851
1887
  padding: '16px 24px',
852
- borderTop: '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
1888
+ borderTop:
1889
+ '1px solid var(--dauth-border, rgba(255, 255, 255, 0.08))',
853
1890
  flexShrink: 0,
854
1891
  ...(!isDesktop
855
1892
  ? {
856
1893
  position: 'sticky' as const,
857
1894
  bottom: 0,
858
1895
  zIndex: 1,
859
- backgroundColor: 'var(--dauth-surface, #1a1a2e)',
860
- paddingBottom: 'max(16px, env(safe-area-inset-bottom))',
1896
+ backgroundColor:
1897
+ 'var(--dauth-surface, #1a1a2e)',
1898
+ paddingBottom:
1899
+ 'max(16px, env(safe-area-inset-bottom))',
861
1900
  }
862
1901
  : {}),
863
1902
  });