@umituz/web-dashboard 3.2.4 → 4.0.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.
Files changed (125) hide show
  1. package/README.md +202 -14
  2. package/package.json +21 -29
  3. package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
  4. package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
  5. package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
  6. package/src/domains/analytics/components/MetricCard.tsx +42 -64
  7. package/src/domains/analytics/hooks/index.ts +7 -1
  8. package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
  9. package/src/domains/analytics/index.ts +11 -0
  10. package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
  11. package/src/domains/analytics/services/PerformanceService.ts +117 -69
  12. package/src/domains/analytics/types/analytics.ts +20 -6
  13. package/src/domains/analytics/types/index.ts +3 -0
  14. package/src/domains/analytics/utils/analytics.test.ts +183 -0
  15. package/src/domains/analytics/utils/analytics.ts +26 -9
  16. package/src/domains/analytics/utils/i18nKeys.ts +40 -0
  17. package/src/domains/analytics/utils/index.ts +2 -0
  18. package/src/domains/auth/components/AuthLayout.tsx +63 -54
  19. package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
  20. package/src/domains/auth/components/LoginForm.tsx +167 -226
  21. package/src/domains/auth/components/RegisterForm.tsx +202 -272
  22. package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
  23. package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
  24. package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
  25. package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
  26. package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
  27. package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
  28. package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
  29. package/src/domains/auth/components/shared/index.ts +20 -0
  30. package/src/domains/auth/hooks/index.ts +1 -0
  31. package/src/domains/auth/hooks/useAuth.ts +151 -232
  32. package/src/domains/auth/index.ts +8 -4
  33. package/src/domains/auth/types/auth.ts +26 -5
  34. package/src/domains/auth/types/index.ts +1 -0
  35. package/src/domains/auth/utils/auth.test.ts +182 -0
  36. package/src/domains/auth/utils/auth.ts +71 -130
  37. package/src/domains/auth/utils/i18nKeys.ts +127 -0
  38. package/src/domains/auth/utils/index.ts +7 -1
  39. package/src/domains/auth/utils/secureToken.ts +33 -0
  40. package/src/domains/auth/utils/validationKeys.ts +23 -0
  41. package/src/domains/calendar/hooks/useCalendar.ts +145 -151
  42. package/src/domains/calendar/index.ts +0 -1
  43. package/src/domains/calendar/services/CalendarService.ts +122 -179
  44. package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
  45. package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
  46. package/src/domains/calendar/utils/index.ts +33 -30
  47. package/src/domains/layouts/components/BrandLogo.tsx +12 -9
  48. package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
  49. package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
  50. package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
  51. package/src/domains/layouts/types/layout.ts +7 -1
  52. package/src/domains/layouts/types/user.ts +3 -1
  53. package/src/domains/modules/build.test.tsx +97 -0
  54. package/src/domains/modules/build.tsx +71 -0
  55. package/src/domains/modules/index.ts +2 -0
  56. package/src/domains/modules/types.ts +29 -0
  57. package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
  58. package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
  59. package/src/domains/onboarding/components/PlanStep.tsx +156 -123
  60. package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
  61. package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
  62. package/src/domains/onboarding/components/StepProgress.tsx +62 -0
  63. package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
  64. package/src/domains/onboarding/components/index.ts +4 -0
  65. package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
  66. package/src/domains/onboarding/index.ts +1 -0
  67. package/src/domains/onboarding/types/onboarding.ts +57 -17
  68. package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
  69. package/src/domains/onboarding/utils/index.ts +2 -0
  70. package/src/domains/permissions/PermissionsContext.tsx +103 -0
  71. package/src/domains/permissions/hasPermission.test.ts +50 -0
  72. package/src/domains/permissions/hasPermission.ts +38 -0
  73. package/src/domains/permissions/index.ts +19 -0
  74. package/src/domains/permissions/types.ts +35 -0
  75. package/src/domains/permissions/utils/i18nKeys.ts +11 -0
  76. package/src/domains/session/DemoLogin.tsx +50 -0
  77. package/src/domains/session/OrgSwitcher.tsx +110 -0
  78. package/src/domains/session/SessionContext.tsx +150 -0
  79. package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
  80. package/src/domains/session/index.ts +18 -0
  81. package/src/domains/session/types.ts +47 -0
  82. package/src/domains/session/utils/i18nKeys.ts +12 -0
  83. package/src/domains/settings/components/AuditLogList.tsx +59 -0
  84. package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
  85. package/src/domains/settings/components/SettingsLayout.tsx +75 -60
  86. package/src/domains/settings/components/SettingsSection.tsx +35 -22
  87. package/src/domains/settings/components/UserManagementTable.tsx +87 -0
  88. package/src/domains/settings/components/index.ts +10 -0
  89. package/src/domains/settings/hooks/useSettings.ts +77 -52
  90. package/src/domains/settings/types/settings.ts +2 -0
  91. package/src/domains/settings/utils/i18nKeys.ts +18 -0
  92. package/src/domains/settings/utils/settings.ts +11 -4
  93. package/src/i18n/I18nContext.tsx +33 -0
  94. package/src/i18n/defaultT.test.ts +43 -0
  95. package/src/i18n/defaultT.ts +34 -0
  96. package/src/i18n/index.ts +7 -0
  97. package/src/i18n/types.ts +13 -0
  98. package/src/index.ts +1 -7
  99. package/src/primitives/Button.tsx +30 -0
  100. package/src/primitives/Checkbox.tsx +40 -0
  101. package/src/primitives/Input.tsx +19 -0
  102. package/src/primitives/Skeleton.tsx +26 -0
  103. package/src/primitives/cn.ts +15 -0
  104. package/src/primitives/index.ts +23 -0
  105. package/src/styles/dashboard.css +3045 -0
  106. package/src/styles/tokens.contract.test.ts +66 -0
  107. package/src/styles/tokens.css +124 -0
  108. package/src/domains/billing/components/BillingLayout.tsx +0 -63
  109. package/src/domains/billing/components/BillingPage.tsx +0 -49
  110. package/src/domains/billing/components/BillingPortal.tsx +0 -198
  111. package/src/domains/billing/components/InvoiceCard.tsx +0 -143
  112. package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
  113. package/src/domains/billing/components/PlanComparison.tsx +0 -199
  114. package/src/domains/billing/components/UsageCard.tsx +0 -103
  115. package/src/domains/billing/components/index.ts +0 -13
  116. package/src/domains/billing/constants/billing.ts +0 -69
  117. package/src/domains/billing/hooks/index.ts +0 -7
  118. package/src/domains/billing/hooks/useBilling.ts +0 -385
  119. package/src/domains/billing/index.ts +0 -73
  120. package/src/domains/billing/types/billing.ts +0 -342
  121. package/src/domains/billing/types/index.ts +0 -30
  122. package/src/domains/billing/utils/billing.ts +0 -360
  123. package/src/domains/billing/utils/index.ts +0 -31
  124. package/src/domains/billing/utils/polar.ts +0 -63
  125. package/src/domains/calendar/components/index.ts +0 -7
@@ -1,16 +1,40 @@
1
1
  /**
2
- * Reset Password Form Component
2
+ * ResetPasswordForm
3
3
  *
4
- * Password reset confirmation form
4
+ * Composed from shared auth primitives (PasswordInput, PasswordStrengthIndicator,
5
+ * AuthErrorBanner). All copy is i18n-keyed.
5
6
  */
6
7
 
7
8
  import { useState } from "react";
8
9
  import { useNavigate } from "react-router-dom";
9
- import { Eye, EyeOff, Loader2, CheckCircle } from "lucide-react";
10
- import { cn } from "@umituz/web-design-system/utils";
11
- import { Button } from "@umituz/web-design-system/atoms";
12
- import type { ResetPasswordFormProps } from "../types/auth";
13
- import { validateResetPassword, calculatePasswordStrength } from "../utils/auth";
10
+ import { useT } from "../../../i18n";
11
+ import { Loader2, CheckCircle2 } from "lucide-react";
12
+ import { Button, Input } from "../../../primitives";
13
+ import { cn } from "../../../primitives";
14
+ import type { ResetPasswordFormProps, Translate } from "../types/auth";
15
+ import { validateResetPassword } from "../utils/auth";
16
+ import { AUTH_KEYS } from "../utils/i18nKeys";
17
+ import { AUTH_VALIDATION_KEYS } from "../utils/validationKeys";
18
+ import {
19
+ AuthErrorBanner,
20
+ PasswordInput,
21
+ PasswordStrengthIndicator,
22
+ } from "./shared";
23
+
24
+ const KEYS = {
25
+ title: 'auth.resetPassword.title',
26
+ description: 'auth.resetPassword.description',
27
+ newPassword: 'auth.resetPassword.newPassword',
28
+ confirmNewPassword: 'auth.resetPassword.confirmNewPassword',
29
+ passwordsDoNotMatch: AUTH_VALIDATION_KEYS.passwordsDoNotMatch,
30
+ submit: 'auth.resetPassword.submit',
31
+ submitting: 'auth.resetPassword.submitting',
32
+ successTitle: 'auth.resetPassword.successTitle',
33
+ successMessage: 'auth.resetPassword.successMessage',
34
+ goToSignIn: 'auth.resetPassword.goToSignIn',
35
+ invalidResetData: AUTH_VALIDATION_KEYS.passwordTooShort,
36
+ resetFailed: 'auth.errors.resetFailed',
37
+ } as const;
14
38
 
15
39
  export const ResetPasswordForm = ({
16
40
  config,
@@ -19,81 +43,70 @@ export const ResetPasswordForm = ({
19
43
  onError,
20
44
  }: ResetPasswordFormProps) => {
21
45
  const navigate = useNavigate();
46
+ const t = useT();
47
+ const translate: Translate = t;
48
+
22
49
  const [password, setPassword] = useState("");
23
50
  const [confirmPassword, setConfirmPassword] = useState("");
24
- const [showPassword, setShowPassword] = useState(false);
25
- const [showConfirmPassword, setShowConfirmPassword] = useState(false);
26
51
  const [isLoading, setIsLoading] = useState(false);
27
52
  const [error, setError] = useState<string | null>(null);
28
53
  const [success, setSuccess] = useState(false);
29
54
 
55
+ const passwordsMatch = password === confirmPassword;
56
+
30
57
  const handleSubmit = async (e: React.FormEvent) => {
31
58
  e.preventDefault();
32
59
  setError(null);
33
60
 
34
- // Validate
35
- const validation = validateResetPassword({
36
- token,
37
- password,
38
- confirmPassword,
39
- });
61
+ const validation = validateResetPassword({ token, password, confirmPassword });
40
62
  if (!validation.valid) {
41
- setError(validation.error || "Invalid reset data");
42
- onError?.(validation.error || "Invalid reset data");
63
+ const message = validation.error ?? KEYS.invalidResetData;
64
+ setError(message);
65
+ onError?.(message);
43
66
  return;
44
67
  }
45
68
 
46
69
  setIsLoading(true);
47
-
48
70
  try {
49
- // In production, call your auth API
50
- // await resetPassword({ token, password });
51
-
52
- // Simulate API call
53
- await new Promise((resolve) => setTimeout(resolve, 1000));
54
-
71
+ // Hand off the actual reset to the consumer. The form is
72
+ // pure orchestration; no network knowledge here. Token and the
73
+ // new password are passed through so the consumer can persist it.
74
+ await onSuccess?.({ token, password });
75
+ // Don't keep the new password in component state after completion.
76
+ setPassword("");
77
+ setConfirmPassword("");
55
78
  setSuccess(true);
56
- await onSuccess?.();
57
79
  } catch (err) {
58
- const errorMessage = err instanceof Error ? err.message : "Failed to reset password";
59
- setError(errorMessage);
60
- onError?.(errorMessage);
80
+ const message = err instanceof Error ? err.message : KEYS.resetFailed;
81
+ setError(message);
82
+ onError?.(message);
61
83
  } finally {
62
84
  setIsLoading(false);
63
85
  }
64
86
  };
65
87
 
66
- const passwordStrength = calculatePasswordStrength(password);
67
- const passwordsMatch = password === confirmPassword;
68
-
69
88
  if (success) {
70
89
  return (
71
- <div className="w-full max-w-md space-y-6">
72
- {/* Success Message */}
73
- <div className="text-center space-y-4">
74
- <div className="flex justify-center">
75
- <div className="w-16 h-16 bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center">
76
- <CheckCircle className="h-8 w-8 text-green-600 dark:text-green-500" />
77
- </div>
78
- </div>
79
- <div>
80
- <h1 className="text-2xl font-extrabold text-foreground mb-2">
81
- Password reset successful
82
- </h1>
83
- <p className="text-muted-foreground">
84
- Your password has been updated. You can now sign in with your new password.
85
- </p>
90
+ <div className="udb-form" role="status" aria-live="polite">
91
+ <div className="udb-form__header">
92
+ <div className="udb-success-badge">
93
+ <CheckCircle2 className="udb-success-badge__icon" aria-hidden="true" />
86
94
  </div>
95
+ <h1 className="udb-form__title">
96
+ {translate(KEYS.successTitle)}
97
+ </h1>
98
+ <p className="udb-form__subtitle">
99
+ {translate(KEYS.successMessage)}
100
+ </p>
87
101
  </div>
88
102
 
89
- {/* Sign In Button */}
90
103
  {config.loginRoute && (
91
104
  <Button
92
105
  type="button"
93
106
  onClick={() => navigate(config.loginRoute)}
94
- className="w-full h-12 text-base font-bold rounded-full"
107
+ className="udb-submit-btn udb-submit-btn--pill"
95
108
  >
96
- Go to sign in
109
+ {translate(KEYS.goToSignIn)}
97
110
  </Button>
98
111
  )}
99
112
  </div>
@@ -101,126 +114,67 @@ export const ResetPasswordForm = ({
101
114
  }
102
115
 
103
116
  return (
104
- <form onSubmit={handleSubmit} className="w-full max-w-md space-y-5">
105
- {/* Header */}
106
- <div className="text-center">
107
- <h1 className="text-3xl font-extrabold text-foreground mb-2">
108
- Create new password
117
+ <form onSubmit={handleSubmit} className="udb-form" noValidate>
118
+ <div className="udb-form__header">
119
+ <h1 className="udb-form__title udb-form__title--lg">
120
+ {translate(KEYS.title)}
109
121
  </h1>
110
- <p className="text-muted-foreground">
111
- Enter your new password below
112
- </p>
122
+ <p className="udb-form__subtitle">{translate(KEYS.description)}</p>
113
123
  </div>
114
124
 
115
- {/* Error Message */}
116
- {error && (
117
- <div className="bg-destructive/10 border border-destructive/20 text-destructive px-4 py-3 rounded-lg text-sm">
118
- {error}
119
- </div>
120
- )}
121
-
122
- {/* Password Field */}
123
- <div className="space-y-2">
124
- <label htmlFor="password" className="text-sm font-medium text-foreground">
125
- New Password
126
- </label>
127
- <div className="relative">
128
- <input
129
- id="password"
130
- type={showPassword ? "text" : "password"}
131
- value={password}
132
- onChange={(e) => setPassword(e.target.value)}
133
- placeholder="••••••••"
134
- className={cn(
135
- "w-full px-4 py-3 rounded-lg border bg-background pr-12",
136
- "focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",
137
- "placeholder:text-muted-foreground"
138
- )}
139
- disabled={isLoading}
140
- autoComplete="new-password"
141
- />
142
- <button
143
- type="button"
144
- onClick={() => setShowPassword(!showPassword)}
145
- className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
146
- tabIndex={-1}
147
- >
148
- {showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
149
- </button>
150
- </div>
151
- {/* Password Strength Indicator */}
152
- {password && (
153
- <div className="flex items-center gap-2">
154
- <div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
155
- <div
156
- className={cn(
157
- "h-full transition-all duration-300",
158
- passwordStrength < 25 && "bg-destructive",
159
- passwordStrength >= 25 && passwordStrength < 50 && "bg-orange-500",
160
- passwordStrength >= 50 && passwordStrength < 75 && "bg-yellow-500",
161
- passwordStrength >= 75 && "bg-green-500"
162
- )}
163
- style={{ width: `${passwordStrength}%` }}
164
- />
165
- </div>
166
- <span className="text-xs text-muted-foreground">
167
- {passwordStrength < 25 && "Weak"}
168
- {passwordStrength >= 25 && passwordStrength < 50 && "Fair"}
169
- {passwordStrength >= 50 && passwordStrength < 75 && "Good"}
170
- {passwordStrength >= 75 && "Strong"}
171
- </span>
172
- </div>
173
- )}
125
+ <AuthErrorBanner message={error} translate={translate} />
126
+
127
+ <div className="udb-form__group">
128
+ <PasswordInput
129
+ id="reset-password"
130
+ labelKey={KEYS.newPassword}
131
+ placeholderKey={AUTH_KEYS.placeholders.password}
132
+ value={password}
133
+ onChange={setPassword}
134
+ translate={translate}
135
+ autoComplete="new-password"
136
+ disabled={isLoading}
137
+ required
138
+ />
139
+ <PasswordStrengthIndicator password={password} translate={translate} />
174
140
  </div>
175
141
 
176
- {/* Confirm Password Field */}
177
- <div className="space-y-2">
178
- <label htmlFor="confirmPassword" className="text-sm font-medium text-foreground">
179
- Confirm New Password
142
+ <div className="udb-field">
143
+ <label htmlFor="reset-confirm" className="udb-field__label">
144
+ {translate(KEYS.confirmNewPassword)}
145
+ <span className="udb-field__required" aria-hidden="true">*</span>
180
146
  </label>
181
- <div className="relative">
182
- <input
183
- id="confirmPassword"
184
- type={showConfirmPassword ? "text" : "password"}
185
- value={confirmPassword}
186
- onChange={(e) => setConfirmPassword(e.target.value)}
187
- placeholder="••••••••"
188
- className={cn(
189
- "w-full px-4 py-3 rounded-lg border bg-background pr-12",
190
- "focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",
191
- "placeholder:text-muted-foreground",
192
- confirmPassword && !passwordsMatch && "border-destructive"
193
- )}
194
- disabled={isLoading}
195
- autoComplete="new-password"
196
- />
197
- <button
198
- type="button"
199
- onClick={() => setShowConfirmPassword(!showConfirmPassword)}
200
- className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
201
- tabIndex={-1}
202
- >
203
- {showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
204
- </button>
205
- </div>
147
+ <Input
148
+ id="reset-confirm"
149
+ type="password"
150
+ autoComplete="new-password"
151
+ required
152
+ disabled={isLoading}
153
+ value={confirmPassword}
154
+ placeholder={translate(AUTH_KEYS.placeholders.password)}
155
+ onChange={(e) => setConfirmPassword(e.target.value)}
156
+ aria-invalid={Boolean(confirmPassword) && !passwordsMatch}
157
+ className={cn(confirmPassword && !passwordsMatch && 'udb-input--error')}
158
+ />
206
159
  {confirmPassword && !passwordsMatch && (
207
- <p className="text-xs text-destructive">Passwords do not match</p>
160
+ <p className="udb-field__error" role="alert">
161
+ {translate(KEYS.passwordsDoNotMatch)}
162
+ </p>
208
163
  )}
209
164
  </div>
210
165
 
211
- {/* Submit Button */}
212
166
  <Button
213
167
  type="submit"
214
- className="w-full h-12 text-base font-bold rounded-full"
168
+ className="udb-submit-btn udb-submit-btn--pill"
215
169
  disabled={isLoading || !passwordsMatch}
216
170
  >
217
171
  {isLoading ? (
218
172
  <>
219
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
220
- Resetting...
173
+ <Loader2 className="udb-btn__spinner" aria-hidden="true" />
174
+ {translate(KEYS.submitting)}
221
175
  </>
222
176
  ) : (
223
- "Reset password"
177
+ translate(KEYS.submit)
224
178
  )}
225
179
  </Button>
226
180
  </form>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * AuthErrorBanner
3
+ *
4
+ * Inline error display for auth forms. Renders an alert with the
5
+ * provided i18n key resolved by the consumer's translation function.
6
+ */
7
+
8
+ import { AlertCircle } from "lucide-react";
9
+ import { cn } from "../../../../primitives";
10
+
11
+ export interface AuthErrorBannerProps {
12
+ /** i18n key for the message (already resolved by the consumer) */
13
+ message?: string | null;
14
+ /** Translation function used to resolve the key */
15
+ translate: (key: string) => string;
16
+ /** Optional className for layout integration */
17
+ className?: string;
18
+ }
19
+
20
+ export const AuthErrorBanner = ({ message, translate, className }: AuthErrorBannerProps) => {
21
+ if (!message) return null;
22
+
23
+ const resolved = message.includes('.') ? translate(message) : message;
24
+
25
+ return (
26
+ <div
27
+ role="alert"
28
+ aria-live="polite"
29
+ className={cn("udb-form-alert", className)}
30
+ >
31
+ <AlertCircle className="udb-form-alert__icon" aria-hidden="true" />
32
+ <span>{resolved}</span>
33
+ </div>
34
+ );
35
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * EmailInput
3
+ *
4
+ * Standardized email input used across all auth forms.
5
+ * Renders label, input, error state, and disabled state consistently.
6
+ */
7
+
8
+ import { forwardRef } from "react";
9
+ import { Input } from "../../../../primitives";
10
+ import { cn } from "../../../../primitives";
11
+
12
+ export interface EmailInputProps {
13
+ /** Input id (used to wire up the label) */
14
+ id: string;
15
+ /** Label i18n key — resolved via `translate` */
16
+ labelKey: string;
17
+ /** Placeholder i18n key */
18
+ placeholderKey: string;
19
+ /** Current value */
20
+ value: string;
21
+ /** Change handler */
22
+ onChange: (value: string) => void;
23
+ /** Translation function */
24
+ translate: (key: string) => string;
25
+ /** Auto-complete hint (default: "email") */
26
+ autoComplete?: string;
27
+ /** Disable the input */
28
+ disabled?: boolean;
29
+ /** Whether to mark the field as required (renders an asterisk) */
30
+ required?: boolean;
31
+ /** Optional error message to display below the field */
32
+ error?: string | null;
33
+ /** Custom className */
34
+ className?: string;
35
+ }
36
+
37
+ export const EmailInput = forwardRef<HTMLInputElement, EmailInputProps>(
38
+ (
39
+ {
40
+ id,
41
+ labelKey,
42
+ placeholderKey,
43
+ value,
44
+ onChange,
45
+ translate,
46
+ autoComplete = "email",
47
+ disabled = false,
48
+ required = false,
49
+ error = null,
50
+ className,
51
+ },
52
+ ref,
53
+ ) => {
54
+ return (
55
+ <div className={cn("udb-field", className)}>
56
+ <label htmlFor={id} className="udb-field__label">
57
+ {translate(labelKey)}
58
+ {required && (
59
+ <span className="udb-field__required" aria-hidden="true">
60
+ *
61
+ </span>
62
+ )}
63
+ </label>
64
+ <Input
65
+ ref={ref}
66
+ id={id}
67
+ type="email"
68
+ autoComplete={autoComplete}
69
+ required={required}
70
+ disabled={disabled}
71
+ value={value}
72
+ placeholder={translate(placeholderKey)}
73
+ onChange={(e) => onChange(e.target.value)}
74
+ aria-invalid={Boolean(error)}
75
+ aria-describedby={error ? `${id}-error` : undefined}
76
+ />
77
+ {error && (
78
+ <p
79
+ id={`${id}-error`}
80
+ className="udb-field__error"
81
+ role="alert"
82
+ >
83
+ {error}
84
+ </p>
85
+ )}
86
+ </div>
87
+ );
88
+ },
89
+ );
90
+
91
+ EmailInput.displayName = "EmailInput";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * PasswordInput
3
+ *
4
+ * Password field with show/hide toggle, used across all auth forms.
5
+ * Centralizes the visibility toggle, autoComplete, and label structure
6
+ * so individual forms can stay focused on their unique concerns.
7
+ */
8
+
9
+ import { forwardRef, useState } from "react";
10
+ import { Eye, EyeOff } from "lucide-react";
11
+ import { Input } from "../../../../primitives";
12
+ import { Button } from "../../../../primitives";
13
+ import { cn } from "../../../../primitives";
14
+
15
+ export interface PasswordInputProps {
16
+ /** Input id (used to wire up the label) */
17
+ id: string;
18
+ /** Label i18n key */
19
+ labelKey: string;
20
+ /** Placeholder i18n key */
21
+ placeholderKey: string;
22
+ /** Current value */
23
+ value: string;
24
+ /** Change handler */
25
+ onChange: (value: string) => void;
26
+ /** Translation function */
27
+ translate: (key: string) => string;
28
+ /** Auto-complete hint (e.g., "current-password", "new-password") */
29
+ autoComplete?: string;
30
+ /** Disable the input */
31
+ disabled?: boolean;
32
+ /** Whether to mark the field as required (renders an asterisk) */
33
+ required?: boolean;
34
+ /** Optional error message to display below the field */
35
+ error?: string | null;
36
+ /** Custom className */
37
+ className?: string;
38
+ }
39
+
40
+ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
41
+ (
42
+ {
43
+ id,
44
+ labelKey,
45
+ placeholderKey,
46
+ value,
47
+ onChange,
48
+ translate,
49
+ autoComplete = "current-password",
50
+ disabled = false,
51
+ required = false,
52
+ error = null,
53
+ className,
54
+ },
55
+ ref,
56
+ ) => {
57
+ const [visible, setVisible] = useState(false);
58
+
59
+ return (
60
+ <div className={cn("udb-field", className)}>
61
+ <label htmlFor={id} className="udb-field__label">
62
+ {translate(labelKey)}
63
+ {required && (
64
+ <span className="udb-field__required" aria-hidden="true">
65
+ *
66
+ </span>
67
+ )}
68
+ </label>
69
+ <div className="udb-password-wrap">
70
+ <Input
71
+ ref={ref}
72
+ id={id}
73
+ type={visible ? "text" : "password"}
74
+ autoComplete={autoComplete}
75
+ required={required}
76
+ disabled={disabled}
77
+ value={value}
78
+ placeholder={translate(placeholderKey)}
79
+ onChange={(e) => onChange(e.target.value)}
80
+ aria-invalid={Boolean(error)}
81
+ aria-describedby={error ? `${id}-error` : undefined}
82
+ className="udb-input--with-toggle"
83
+ />
84
+ <Button
85
+ type="button"
86
+ variant="ghost"
87
+ size="icon"
88
+ tabIndex={-1}
89
+ onClick={() => setVisible((prev) => !prev)}
90
+ disabled={disabled}
91
+ aria-label={
92
+ visible
93
+ ? translate('auth.a11y.hidePassword')
94
+ : translate('auth.a11y.showPassword')
95
+ }
96
+ className="udb-password-toggle"
97
+ >
98
+ {visible ? (
99
+ <EyeOff className="udb-password-toggle__icon" aria-hidden="true" />
100
+ ) : (
101
+ <Eye className="udb-password-toggle__icon" aria-hidden="true" />
102
+ )}
103
+ </Button>
104
+ </div>
105
+ {error && (
106
+ <p
107
+ id={`${id}-error`}
108
+ className="udb-field__error"
109
+ role="alert"
110
+ >
111
+ {error}
112
+ </p>
113
+ )}
114
+ </div>
115
+ );
116
+ },
117
+ );
118
+
119
+ PasswordInput.displayName = "PasswordInput";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * PasswordStrengthIndicator
3
+ *
4
+ * Renders a 4-segment strength bar plus a localized label band.
5
+ * The label i18n key is computed from the strength band so the
6
+ * component stays free of hardcoded strings. Bar/label colors come
7
+ * from `--u-*` tones (weak/danger, fair/warning-2, good/warning,
8
+ * strong/success).
9
+ */
10
+
11
+ import { useMemo } from "react";
12
+ import { calculatePasswordStrength } from "../../utils/auth";
13
+ import { cn } from "../../../../primitives";
14
+
15
+ const STRENGTH_KEYS = {
16
+ weak: 'auth.passwordStrength.weak',
17
+ fair: 'auth.passwordStrength.fair',
18
+ good: 'auth.passwordStrength.good',
19
+ strong: 'auth.passwordStrength.strong',
20
+ } as const;
21
+
22
+ type StrengthBand = keyof typeof STRENGTH_KEYS;
23
+
24
+ const scoreToBand = (score: number): StrengthBand => {
25
+ if (score < 25) return 'weak';
26
+ if (score < 50) return 'fair';
27
+ if (score < 75) return 'good';
28
+ return 'strong';
29
+ };
30
+
31
+ export interface PasswordStrengthIndicatorProps {
32
+ /** Password to evaluate */
33
+ password: string;
34
+ /** Translation function */
35
+ translate: (key: string) => string;
36
+ }
37
+
38
+ export const PasswordStrengthIndicator = ({
39
+ password,
40
+ translate,
41
+ }: PasswordStrengthIndicatorProps) => {
42
+ const { band, filledSegments } = useMemo(() => {
43
+ const value = calculatePasswordStrength(password);
44
+ const resolvedBand = scoreToBand(value);
45
+ const segments = Math.round(value / 25);
46
+ return {
47
+ band: resolvedBand,
48
+ filledSegments: segments,
49
+ };
50
+ }, [password]);
51
+
52
+ if (!password) return null;
53
+
54
+ return (
55
+ <div className="udb-strength" aria-live="polite">
56
+ <div className="udb-strength__bars">
57
+ {[0, 1, 2, 3].map((segment) => {
58
+ const isActive = segment < filledSegments;
59
+ return (
60
+ <div
61
+ key={segment}
62
+ className={cn(
63
+ "udb-strength__bar",
64
+ isActive && `udb-strength__bar--${band}`,
65
+ )}
66
+ />
67
+ );
68
+ })}
69
+ </div>
70
+ <p className={cn("udb-strength__label", `udb-strength__label--${band}`)}>
71
+ {translate(STRENGTH_KEYS[band])}
72
+ </p>
73
+ </div>
74
+ );
75
+ };