@syuttechnologies/layout 1.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.
@@ -0,0 +1,545 @@
1
+ import React, { useState, useCallback, useMemo } from 'react';
2
+
3
+ export interface ChangePasswordModalProps {
4
+ isOpen: boolean;
5
+ onClose: () => void;
6
+ onSubmit: (data: ChangePasswordData) => Promise<void> | void;
7
+ username?: string;
8
+ validationRules?: PasswordValidationRules;
9
+ isLoading?: boolean;
10
+ }
11
+
12
+ export interface ChangePasswordData {
13
+ currentPassword: string;
14
+ newPassword: string;
15
+ confirmPassword: string;
16
+ }
17
+
18
+ export interface PasswordValidationRules {
19
+ minLength?: number;
20
+ requireUppercase?: boolean;
21
+ requireNumber?: boolean;
22
+ requireSpecialChar?: boolean;
23
+ preventUsernameMatch?: boolean;
24
+ }
25
+
26
+ interface ValidationError {
27
+ field: 'currentPassword' | 'newPassword' | 'confirmPassword' | 'general';
28
+ message: string;
29
+ }
30
+
31
+ interface PasswordStrength {
32
+ score: number;
33
+ label: string;
34
+ color: string;
35
+ }
36
+
37
+ const DEFAULT_VALIDATION_RULES: Required<PasswordValidationRules> = {
38
+ minLength: 6,
39
+ requireUppercase: true,
40
+ requireNumber: true,
41
+ requireSpecialChar: true,
42
+ preventUsernameMatch: true,
43
+ };
44
+
45
+ const validatePassword = (
46
+ password: string,
47
+ rules: Required<PasswordValidationRules>,
48
+ username?: string
49
+ ): string[] => {
50
+ const errors: string[] = [];
51
+
52
+ if (password.length < rules.minLength) {
53
+ errors.push(`Password must be at least ${rules.minLength} characters`);
54
+ }
55
+ if (rules.requireUppercase && !/[A-Z]/.test(password)) {
56
+ errors.push('Password must contain at least one uppercase letter');
57
+ }
58
+ if (rules.requireNumber && !/[0-9]/.test(password)) {
59
+ errors.push('Password must contain at least one number');
60
+ }
61
+ if (rules.requireSpecialChar && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
62
+ errors.push('Password must contain at least one special character');
63
+ }
64
+ if (rules.preventUsernameMatch && username && password.toLowerCase() === username.toLowerCase()) {
65
+ errors.push('Password cannot be the same as username');
66
+ }
67
+
68
+ return errors;
69
+ };
70
+
71
+ const calculatePasswordStrength = (password: string): PasswordStrength => {
72
+ let score = 0;
73
+
74
+ if (password.length >= 6) score++;
75
+ if (password.length >= 10) score++;
76
+ if (/[A-Z]/.test(password)) score++;
77
+ if (/[0-9]/.test(password)) score++;
78
+ if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) score++;
79
+
80
+ score = Math.min(4, Math.floor(score * 0.8));
81
+
82
+ const strengthMap: Record<number, { label: string; color: string }> = {
83
+ 0: { label: 'Very Weak', color: '#ef4444' },
84
+ 1: { label: 'Weak', color: '#f97316' },
85
+ 2: { label: 'Fair', color: '#eab308' },
86
+ 3: { label: 'Good', color: '#22c55e' },
87
+ 4: { label: 'Strong', color: '#16a34a' },
88
+ };
89
+
90
+ return { score, ...strengthMap[score] };
91
+ };
92
+
93
+ const EyeIcon = () => (
94
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
95
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
96
+ <circle cx="12" cy="12" r="3" />
97
+ </svg>
98
+ );
99
+
100
+ const EyeOffIcon = () => (
101
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
102
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
103
+ <line x1="1" y1="1" x2="23" y2="23" />
104
+ </svg>
105
+ );
106
+
107
+ const CloseIcon = () => (
108
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
109
+ <line x1="18" y1="6" x2="6" y2="18" />
110
+ <line x1="6" y1="6" x2="18" y2="18" />
111
+ </svg>
112
+ );
113
+
114
+ const LockIcon = () => (
115
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
116
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
117
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
118
+ </svg>
119
+ );
120
+
121
+ const CheckIcon = () => (
122
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
123
+ <polyline points="20 6 9 17 4 12" />
124
+ </svg>
125
+ );
126
+
127
+ const XIcon = () => (
128
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
129
+ <line x1="18" y1="6" x2="6" y2="18" />
130
+ <line x1="6" y1="6" x2="18" y2="18" />
131
+ </svg>
132
+ );
133
+
134
+ const styles = {
135
+ backdrop: {
136
+ position: 'fixed' as const,
137
+ inset: 0,
138
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
139
+ zIndex: 9998,
140
+ },
141
+ modal: {
142
+ position: 'fixed' as const,
143
+ top: '50%',
144
+ left: '50%',
145
+ transform: 'translate(-50%, -50%)',
146
+ backgroundColor: 'white',
147
+ borderRadius: '12px',
148
+ boxShadow: '0 20px 50px rgba(0, 0, 0, 0.3)',
149
+ width: '100%',
150
+ maxWidth: '440px',
151
+ zIndex: 9999,
152
+ overflow: 'hidden',
153
+ },
154
+ header: {
155
+ display: 'flex',
156
+ alignItems: 'center',
157
+ justifyContent: 'space-between',
158
+ padding: '1.25rem 1.5rem',
159
+ borderBottom: '1px solid #e2e8f0',
160
+ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
161
+ },
162
+ headerTitle: {
163
+ display: 'flex',
164
+ alignItems: 'center',
165
+ gap: '0.75rem',
166
+ color: 'white',
167
+ fontSize: '1.125rem',
168
+ fontWeight: 600,
169
+ margin: 0,
170
+ },
171
+ closeButton: {
172
+ background: 'rgba(255, 255, 255, 0.2)',
173
+ border: 'none',
174
+ borderRadius: '8px',
175
+ padding: '0.5rem',
176
+ cursor: 'pointer',
177
+ color: 'white',
178
+ display: 'flex',
179
+ alignItems: 'center',
180
+ justifyContent: 'center',
181
+ transition: 'background 0.2s',
182
+ },
183
+ content: {
184
+ padding: '1.5rem',
185
+ },
186
+ formGroup: {
187
+ marginBottom: '1.25rem',
188
+ },
189
+ label: {
190
+ display: 'block',
191
+ fontSize: '0.875rem',
192
+ fontWeight: 600,
193
+ color: '#334155',
194
+ marginBottom: '0.5rem',
195
+ },
196
+ inputWrapper: {
197
+ position: 'relative' as const,
198
+ },
199
+ input: {
200
+ width: '100%',
201
+ padding: '0.75rem 2.75rem 0.75rem 1rem',
202
+ fontSize: '0.9375rem',
203
+ border: '2px solid #e2e8f0',
204
+ borderRadius: '8px',
205
+ outline: 'none',
206
+ transition: 'border-color 0.2s, box-shadow 0.2s',
207
+ boxSizing: 'border-box' as const,
208
+ },
209
+ inputError: {
210
+ borderColor: '#ef4444',
211
+ },
212
+ inputFocus: {
213
+ borderColor: '#667eea',
214
+ boxShadow: '0 0 0 3px rgba(102, 126, 234, 0.1)',
215
+ },
216
+ toggleButton: {
217
+ position: 'absolute' as const,
218
+ right: '0.75rem',
219
+ top: '50%',
220
+ transform: 'translateY(-50%)',
221
+ background: 'none',
222
+ border: 'none',
223
+ cursor: 'pointer',
224
+ color: '#64748b',
225
+ padding: '0.25rem',
226
+ display: 'flex',
227
+ alignItems: 'center',
228
+ justifyContent: 'center',
229
+ },
230
+ strengthMeter: {
231
+ marginTop: '0.5rem',
232
+ },
233
+ strengthBar: {
234
+ height: '4px',
235
+ backgroundColor: '#e2e8f0',
236
+ borderRadius: '2px',
237
+ overflow: 'hidden',
238
+ marginBottom: '0.25rem',
239
+ },
240
+ strengthFill: {
241
+ height: '100%',
242
+ transition: 'width 0.3s, background-color 0.3s',
243
+ borderRadius: '2px',
244
+ },
245
+ strengthLabel: {
246
+ fontSize: '0.75rem',
247
+ fontWeight: 500,
248
+ },
249
+ requirements: {
250
+ marginTop: '0.75rem',
251
+ padding: '0.75rem',
252
+ backgroundColor: '#f8fafc',
253
+ borderRadius: '8px',
254
+ border: '1px solid #e2e8f0',
255
+ },
256
+ requirementsTitle: {
257
+ fontSize: '0.75rem',
258
+ fontWeight: 600,
259
+ color: '#64748b',
260
+ marginBottom: '0.5rem',
261
+ textTransform: 'uppercase' as const,
262
+ letterSpacing: '0.05em',
263
+ },
264
+ requirementItem: {
265
+ display: 'flex',
266
+ alignItems: 'center',
267
+ gap: '0.5rem',
268
+ fontSize: '0.8125rem',
269
+ marginBottom: '0.25rem',
270
+ },
271
+ errorText: {
272
+ color: '#ef4444',
273
+ fontSize: '0.8125rem',
274
+ marginTop: '0.375rem',
275
+ display: 'flex',
276
+ alignItems: 'center',
277
+ gap: '0.25rem',
278
+ },
279
+ footer: {
280
+ display: 'flex',
281
+ justifyContent: 'flex-end',
282
+ gap: '0.75rem',
283
+ padding: '1rem 1.5rem',
284
+ borderTop: '1px solid #e2e8f0',
285
+ backgroundColor: '#f8fafc',
286
+ },
287
+ button: {
288
+ padding: '0.625rem 1.25rem',
289
+ fontSize: '0.875rem',
290
+ fontWeight: 600,
291
+ borderRadius: '8px',
292
+ cursor: 'pointer',
293
+ transition: 'all 0.2s',
294
+ border: 'none',
295
+ },
296
+ cancelButton: {
297
+ backgroundColor: 'white',
298
+ color: '#64748b',
299
+ border: '1px solid #e2e8f0',
300
+ },
301
+ submitButton: {
302
+ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
303
+ color: 'white',
304
+ boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
305
+ },
306
+ submitButtonDisabled: {
307
+ background: '#cbd5e1',
308
+ cursor: 'not-allowed',
309
+ boxShadow: 'none',
310
+ },
311
+ };
312
+
313
+ export const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({
314
+ isOpen,
315
+ onClose,
316
+ onSubmit,
317
+ username,
318
+ validationRules,
319
+ isLoading: externalLoading,
320
+ }) => {
321
+ const [currentPassword, setCurrentPassword] = useState('');
322
+ const [newPassword, setNewPassword] = useState('');
323
+ const [confirmPassword, setConfirmPassword] = useState('');
324
+ const [showCurrentPassword, setShowCurrentPassword] = useState(false);
325
+ const [showNewPassword, setShowNewPassword] = useState(false);
326
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
327
+ const [errors, setErrors] = useState<ValidationError[]>([]);
328
+ const [isSubmitting, setIsSubmitting] = useState(false);
329
+ const [focusedField, setFocusedField] = useState<string | null>(null);
330
+
331
+ const rules = useMemo(
332
+ () => ({ ...DEFAULT_VALIDATION_RULES, ...validationRules }),
333
+ [validationRules]
334
+ );
335
+
336
+ const passwordStrength = useMemo(
337
+ () => calculatePasswordStrength(newPassword),
338
+ [newPassword]
339
+ );
340
+
341
+ const requirements = useMemo(() => {
342
+ return [
343
+ { id: 'length', label: `At least ${rules.minLength} characters`, met: newPassword.length >= rules.minLength },
344
+ { id: 'uppercase', label: 'One uppercase letter', met: /[A-Z]/.test(newPassword), required: rules.requireUppercase },
345
+ { id: 'number', label: 'One number', met: /[0-9]/.test(newPassword), required: rules.requireNumber },
346
+ { id: 'special', label: 'One special character', met: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(newPassword), required: rules.requireSpecialChar },
347
+ { id: 'notUsername', label: 'Not same as username', met: !username || newPassword.toLowerCase() !== username.toLowerCase(), required: rules.preventUsernameMatch && !!username },
348
+ ].filter((req) => req.required !== false);
349
+ }, [newPassword, rules, username]);
350
+
351
+ const isFormValid = useMemo(() => {
352
+ if (!currentPassword || !newPassword || !confirmPassword) return false;
353
+ if (newPassword !== confirmPassword) return false;
354
+ if (requirements.some((req) => !req.met)) return false;
355
+ return true;
356
+ }, [currentPassword, newPassword, confirmPassword, requirements]);
357
+
358
+ const getFieldError = useCallback(
359
+ (field: ValidationError['field']) => errors.find((e) => e.field === field)?.message,
360
+ [errors]
361
+ );
362
+
363
+ const handleSubmit = useCallback(
364
+ async (e: React.FormEvent) => {
365
+ e.preventDefault();
366
+ const newErrors: ValidationError[] = [];
367
+
368
+ if (!currentPassword) {
369
+ newErrors.push({ field: 'currentPassword', message: 'Current password is required' });
370
+ }
371
+
372
+ const passwordErrors = validatePassword(newPassword, rules, username);
373
+ if (passwordErrors.length > 0) {
374
+ newErrors.push({ field: 'newPassword', message: passwordErrors[0] });
375
+ }
376
+
377
+ if (!confirmPassword) {
378
+ newErrors.push({ field: 'confirmPassword', message: 'Please confirm your new password' });
379
+ } else if (newPassword !== confirmPassword) {
380
+ newErrors.push({ field: 'confirmPassword', message: 'Passwords do not match' });
381
+ }
382
+
383
+ if (currentPassword && newPassword && currentPassword === newPassword) {
384
+ newErrors.push({ field: 'newPassword', message: 'New password must be different from current password' });
385
+ }
386
+
387
+ if (newErrors.length > 0) {
388
+ setErrors(newErrors);
389
+ return;
390
+ }
391
+
392
+ setErrors([]);
393
+ setIsSubmitting(true);
394
+
395
+ try {
396
+ await onSubmit({ currentPassword, newPassword, confirmPassword });
397
+ setCurrentPassword('');
398
+ setNewPassword('');
399
+ setConfirmPassword('');
400
+ onClose();
401
+ } catch (error: any) {
402
+ setErrors([{ field: 'general', message: error?.message || 'Failed to change password.' }]);
403
+ } finally {
404
+ setIsSubmitting(false);
405
+ }
406
+ },
407
+ [currentPassword, newPassword, confirmPassword, rules, username, onSubmit, onClose]
408
+ );
409
+
410
+ const handleClose = useCallback(() => {
411
+ if (!isSubmitting && !externalLoading) {
412
+ setCurrentPassword('');
413
+ setNewPassword('');
414
+ setConfirmPassword('');
415
+ setErrors([]);
416
+ onClose();
417
+ }
418
+ }, [isSubmitting, externalLoading, onClose]);
419
+
420
+ if (!isOpen) return null;
421
+
422
+ const loading = isSubmitting || externalLoading;
423
+
424
+ return (
425
+ <>
426
+ <div style={styles.backdrop} onClick={handleClose} />
427
+ <div style={styles.modal}>
428
+ <div style={styles.header}>
429
+ <h2 style={styles.headerTitle}>
430
+ <LockIcon />
431
+ Change Password
432
+ </h2>
433
+ <button style={styles.closeButton} onClick={handleClose} disabled={loading}>
434
+ <CloseIcon />
435
+ </button>
436
+ </div>
437
+
438
+ <form onSubmit={handleSubmit}>
439
+ <div style={styles.content}>
440
+ {getFieldError('general') && (
441
+ <div style={{ padding: '0.75rem', backgroundColor: '#fef2f2', border: '1px solid #fecaca', borderRadius: '8px', marginBottom: '1rem', color: '#dc2626', fontSize: '0.875rem' }}>
442
+ {getFieldError('general')}
443
+ </div>
444
+ )}
445
+
446
+ <div style={styles.formGroup}>
447
+ <label style={styles.label}>Current Password</label>
448
+ <div style={styles.inputWrapper}>
449
+ <input
450
+ type={showCurrentPassword ? 'text' : 'password'}
451
+ value={currentPassword}
452
+ onChange={(e) => setCurrentPassword(e.target.value)}
453
+ onFocus={() => setFocusedField('currentPassword')}
454
+ onBlur={() => setFocusedField(null)}
455
+ placeholder="Enter current password"
456
+ disabled={loading}
457
+ style={{ ...styles.input, ...(getFieldError('currentPassword') ? styles.inputError : {}), ...(focusedField === 'currentPassword' ? styles.inputFocus : {}) }}
458
+ />
459
+ <button type="button" style={styles.toggleButton} onClick={() => setShowCurrentPassword(!showCurrentPassword)} tabIndex={-1}>
460
+ {showCurrentPassword ? <EyeOffIcon /> : <EyeIcon />}
461
+ </button>
462
+ </div>
463
+ {getFieldError('currentPassword') && <div style={styles.errorText}>{getFieldError('currentPassword')}</div>}
464
+ </div>
465
+
466
+ <div style={styles.formGroup}>
467
+ <label style={styles.label}>New Password</label>
468
+ <div style={styles.inputWrapper}>
469
+ <input
470
+ type={showNewPassword ? 'text' : 'password'}
471
+ value={newPassword}
472
+ onChange={(e) => setNewPassword(e.target.value)}
473
+ onFocus={() => setFocusedField('newPassword')}
474
+ onBlur={() => setFocusedField(null)}
475
+ placeholder="Enter new password"
476
+ disabled={loading}
477
+ style={{ ...styles.input, ...(getFieldError('newPassword') ? styles.inputError : {}), ...(focusedField === 'newPassword' ? styles.inputFocus : {}) }}
478
+ />
479
+ <button type="button" style={styles.toggleButton} onClick={() => setShowNewPassword(!showNewPassword)} tabIndex={-1}>
480
+ {showNewPassword ? <EyeOffIcon /> : <EyeIcon />}
481
+ </button>
482
+ </div>
483
+ {getFieldError('newPassword') && <div style={styles.errorText}>{getFieldError('newPassword')}</div>}
484
+
485
+ {newPassword && (
486
+ <div style={styles.strengthMeter}>
487
+ <div style={styles.strengthBar}>
488
+ <div style={{ ...styles.strengthFill, width: `${(passwordStrength.score / 4) * 100}%`, backgroundColor: passwordStrength.color }} />
489
+ </div>
490
+ <span style={{ ...styles.strengthLabel, color: passwordStrength.color }}>{passwordStrength.label}</span>
491
+ </div>
492
+ )}
493
+
494
+ <div style={styles.requirements}>
495
+ <div style={styles.requirementsTitle}>Password Requirements</div>
496
+ {requirements.map((req) => (
497
+ <div key={req.id} style={{ ...styles.requirementItem, color: req.met ? '#16a34a' : '#64748b' }}>
498
+ {req.met ? <span style={{ color: '#16a34a' }}><CheckIcon /></span> : <span style={{ color: '#cbd5e1' }}><XIcon /></span>}
499
+ {req.label}
500
+ </div>
501
+ ))}
502
+ </div>
503
+ </div>
504
+
505
+ <div style={styles.formGroup}>
506
+ <label style={styles.label}>Confirm New Password</label>
507
+ <div style={styles.inputWrapper}>
508
+ <input
509
+ type={showConfirmPassword ? 'text' : 'password'}
510
+ value={confirmPassword}
511
+ onChange={(e) => setConfirmPassword(e.target.value)}
512
+ onFocus={() => setFocusedField('confirmPassword')}
513
+ onBlur={() => setFocusedField(null)}
514
+ placeholder="Confirm new password"
515
+ disabled={loading}
516
+ style={{ ...styles.input, ...(getFieldError('confirmPassword') ? styles.inputError : {}), ...(focusedField === 'confirmPassword' ? styles.inputFocus : {}) }}
517
+ />
518
+ <button type="button" style={styles.toggleButton} onClick={() => setShowConfirmPassword(!showConfirmPassword)} tabIndex={-1}>
519
+ {showConfirmPassword ? <EyeOffIcon /> : <EyeIcon />}
520
+ </button>
521
+ </div>
522
+ {getFieldError('confirmPassword') && <div style={styles.errorText}>{getFieldError('confirmPassword')}</div>}
523
+ {confirmPassword && newPassword && confirmPassword === newPassword && (
524
+ <div style={{ color: '#16a34a', fontSize: '0.8125rem', marginTop: '0.375rem', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
525
+ <CheckIcon /> Passwords match
526
+ </div>
527
+ )}
528
+ </div>
529
+ </div>
530
+
531
+ <div style={styles.footer}>
532
+ <button type="button" style={{ ...styles.button, ...styles.cancelButton }} onClick={handleClose} disabled={loading}>
533
+ Cancel
534
+ </button>
535
+ <button type="submit" style={{ ...styles.button, ...styles.submitButton, ...(!isFormValid || loading ? styles.submitButtonDisabled : {}) }} disabled={!isFormValid || loading}>
536
+ {loading ? 'Changing...' : 'Change Password'}
537
+ </button>
538
+ </div>
539
+ </form>
540
+ </div>
541
+ </>
542
+ );
543
+ };
544
+
545
+ export default ChangePasswordModal;