cynx-lib 1.3.80 → 1.3.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gen.js +77 -77
- package/package.json +1 -1
- package/ui/components/Drawer.jsx +208 -208
- package/ui/components/Layout.jsx +138 -138
- package/ui/components/LoginPage.jsx +405 -405
- package/ui/components/Pagination.jsx +145 -145
- package/ui/components/PrivacyBlur.jsx +603 -603
- package/ui/components/Sidebar.jsx +820 -820
- package/utils/index.js +20 -20
- package/utils/utils/duration.js +139 -139
- package/utils/utils/oidc.js +214 -214
- package/utils/utils/realtime.js +1634 -1634
- package/utils/utils/session.js +58 -58
- package/utils/utils/storage.js +393 -393
- package/utils/utils/storage_manager.cjs +55 -4
- package/utils/utils/storage_manager.js +64 -4
- package/utils/utils/ui.js +111 -111
- package/vue/components/Badge.vue +79 -79
- package/vue/components/Button.vue +209 -209
- package/vue/components/Checkbox.vue +103 -103
- package/vue/components/CircularProgress.vue +383 -383
- package/vue/components/Divider.vue +94 -94
- package/vue/components/Drawer.vue +243 -243
- package/vue/components/InfoBadge.vue +223 -223
- package/vue/components/Input.vue +341 -341
- package/vue/components/LinearProgress.vue +268 -268
- package/vue/components/Modal.vue +226 -226
- package/vue/components/Pagination.vue +199 -199
- package/vue/components/Select.vue +713 -713
- package/vue/components/Sidebar.vue +955 -955
- package/vue/components/Skeleton.vue +91 -91
- package/vue/components/Spinner.vue +263 -263
- package/vue/components/StatusTag.vue +89 -89
- package/vue/components/TabBar.vue +176 -176
- package/vue/components/Table.vue +388 -388
- package/vue/components/TimePicker.vue +399 -399
- package/vue/components/Toggle.vue +115 -115
- package/vue/components/XTable.vue +119 -119
- package/vue/composables/useAutoSkeletonRows.ts +71 -71
- package/vue/index.d.ts +67 -67
- package/vue/index.js +86 -86
|
@@ -1,405 +1,405 @@
|
|
|
1
|
-
import { useState, useEffect, useRef } from 'react';
|
|
2
|
-
import { Fingerprint, Lock } from 'lucide-react';
|
|
3
|
-
import Button from './Button.jsx';
|
|
4
|
-
import Input from './Input.jsx';
|
|
5
|
-
import Spinner from './Spinner.jsx';
|
|
6
|
-
import Divider from './Divider.jsx';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Universal White-Label LoginPage component for cynx-lib.
|
|
10
|
-
* Supports Password login, Multi-Provider Captcha (Cap, Turnstile, reCAPTCHA, custom),
|
|
11
|
-
* Passkeys (WebAuthn / Biometrics), and SSO providers.
|
|
12
|
-
* STRICT NO EMOJIS!
|
|
13
|
-
*/
|
|
14
|
-
export default function LoginPage({
|
|
15
|
-
logo,
|
|
16
|
-
logoAlt = 'Logo',
|
|
17
|
-
title = 'Вход в систему',
|
|
18
|
-
subtitle,
|
|
19
|
-
background = 'var(--grey-1, var(--bg, #f4f5f8))',
|
|
20
|
-
cardMaxWidth = 330,
|
|
21
|
-
|
|
22
|
-
// Password Login
|
|
23
|
-
showPasswordLogin = true,
|
|
24
|
-
usernameLabel = 'Логин',
|
|
25
|
-
passwordLabel = 'Пароль',
|
|
26
|
-
usernamePlaceholder = 'Введите логин',
|
|
27
|
-
passwordPlaceholder = 'Введите пароль',
|
|
28
|
-
submitButtonText = 'Войти',
|
|
29
|
-
submitButtonLoadingText = 'Вход...',
|
|
30
|
-
onSubmit,
|
|
31
|
-
|
|
32
|
-
// Passkey Login
|
|
33
|
-
showPasskey = false,
|
|
34
|
-
passkeyButtonText = 'Войти через Passkey',
|
|
35
|
-
passkeyIcon,
|
|
36
|
-
onPasskeyLogin,
|
|
37
|
-
passkeyLoading = false,
|
|
38
|
-
|
|
39
|
-
// SSO Providers
|
|
40
|
-
ssoProviders = [],
|
|
41
|
-
ssoDividerText,
|
|
42
|
-
|
|
43
|
-
// Captcha Configuration
|
|
44
|
-
captcha,
|
|
45
|
-
|
|
46
|
-
// Errors, Notices & States
|
|
47
|
-
error: externalError,
|
|
48
|
-
notice,
|
|
49
|
-
loading: externalLoading = false,
|
|
50
|
-
|
|
51
|
-
// Custom Slots
|
|
52
|
-
headerSlot,
|
|
53
|
-
footerSlot,
|
|
54
|
-
extraFields,
|
|
55
|
-
noAuthMethodsText = 'Нет доступных способов входа. Обратитесь к администратору.',
|
|
56
|
-
}) {
|
|
57
|
-
const [username, setUsername] = useState('');
|
|
58
|
-
const [password, setPassword] = useState('');
|
|
59
|
-
const [internalError, setInternalError] = useState('');
|
|
60
|
-
const [internalLoading, setInternalLoading] = useState(false);
|
|
61
|
-
const [pageReady, setPageReady] = useState(false);
|
|
62
|
-
const [capToken, setCapToken] = useState('');
|
|
63
|
-
const [passkeyInternalLoading, setPasskeyInternalLoading] = useState(false);
|
|
64
|
-
const capRef = useRef(null);
|
|
65
|
-
|
|
66
|
-
const error = externalError !== undefined ? externalError : internalError;
|
|
67
|
-
const loading = externalLoading || internalLoading;
|
|
68
|
-
const isPasskeyBusy = passkeyLoading || passkeyInternalLoading;
|
|
69
|
-
|
|
70
|
-
const isCaptchaEnabled = !!(captcha && (captcha.enabled !== false && (captcha.provider || captcha.endpoint || captcha.siteKey)));
|
|
71
|
-
const captchaProvider = captcha?.provider || 'cap';
|
|
72
|
-
|
|
73
|
-
// 1. Setup Captcha Event Listeners (Cap Anti-Bot Widget)
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (!isCaptchaEnabled || captchaProvider !== 'cap') return;
|
|
76
|
-
const el = capRef.current;
|
|
77
|
-
if (!el) return;
|
|
78
|
-
|
|
79
|
-
const handleSolve = (e) => {
|
|
80
|
-
setCapToken(e.detail?.token || '');
|
|
81
|
-
};
|
|
82
|
-
const handleReset = () => {
|
|
83
|
-
setCapToken('');
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
el.addEventListener('solve', handleSolve);
|
|
87
|
-
el.addEventListener('reset', handleReset);
|
|
88
|
-
return () => {
|
|
89
|
-
el.removeEventListener('solve', handleSolve);
|
|
90
|
-
el.removeEventListener('reset', handleReset);
|
|
91
|
-
};
|
|
92
|
-
}, [pageReady, showPasswordLogin, isCaptchaEnabled, captchaProvider]);
|
|
93
|
-
|
|
94
|
-
// 2. Preload Fonts & Logo to prevent Layout Shifts (FOUC)
|
|
95
|
-
useEffect(() => {
|
|
96
|
-
let isMounted = true;
|
|
97
|
-
|
|
98
|
-
const prepareAssets = async () => {
|
|
99
|
-
const tasks = [];
|
|
100
|
-
|
|
101
|
-
if (typeof document !== 'undefined' && document.fonts && typeof document.fonts.load === 'function') {
|
|
102
|
-
tasks.push(document.fonts.load('13px Manrope').catch(() => {}));
|
|
103
|
-
tasks.push(document.fonts.load('600 11px Manrope').catch(() => {}));
|
|
104
|
-
tasks.push(document.fonts.load('700 14px Manrope').catch(() => {}));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (typeof logo === 'string' && logo.trim()) {
|
|
108
|
-
const logoPromise = new Promise((resolve) => {
|
|
109
|
-
const img = new Image();
|
|
110
|
-
img.onload = resolve;
|
|
111
|
-
img.onerror = resolve;
|
|
112
|
-
img.src = logo;
|
|
113
|
-
if (typeof img.decode === 'function') {
|
|
114
|
-
img.decode().then(resolve).catch(resolve);
|
|
115
|
-
}
|
|
116
|
-
setTimeout(resolve, 300);
|
|
117
|
-
});
|
|
118
|
-
tasks.push(logoPromise);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
await Promise.all(tasks);
|
|
122
|
-
if (isMounted) {
|
|
123
|
-
setPageReady(true);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
prepareAssets();
|
|
128
|
-
|
|
129
|
-
return () => {
|
|
130
|
-
isMounted = false;
|
|
131
|
-
};
|
|
132
|
-
}, [logo]);
|
|
133
|
-
|
|
134
|
-
// 3. Form Submission
|
|
135
|
-
const handleSubmit = async (e) => {
|
|
136
|
-
if (e && e.preventDefault) e.preventDefault();
|
|
137
|
-
if (loading) return;
|
|
138
|
-
|
|
139
|
-
if (!username.trim() || !password) {
|
|
140
|
-
setInternalError('Введите логин и пароль');
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (isCaptchaEnabled && !capToken) {
|
|
145
|
-
setInternalError('Пожалуйста, подтвердите капчу');
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
setInternalError('');
|
|
150
|
-
setInternalLoading(true);
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
if (typeof onSubmit === 'function') {
|
|
154
|
-
await onSubmit({
|
|
155
|
-
username: username.trim(),
|
|
156
|
-
password,
|
|
157
|
-
captchaToken: isCaptchaEnabled ? capToken : undefined,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
} catch (err) {
|
|
161
|
-
setInternalError(err.message || 'Ошибка авторизации');
|
|
162
|
-
if (capRef.current && typeof capRef.current.reset === 'function') {
|
|
163
|
-
capRef.current.reset();
|
|
164
|
-
}
|
|
165
|
-
setCapToken('');
|
|
166
|
-
} finally {
|
|
167
|
-
setInternalLoading(false);
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
// 4. Passkey Action Handler
|
|
172
|
-
const handlePasskeyClick = async () => {
|
|
173
|
-
if (isPasskeyBusy) return;
|
|
174
|
-
setInternalError('');
|
|
175
|
-
setPasskeyInternalLoading(true);
|
|
176
|
-
try {
|
|
177
|
-
if (typeof onPasskeyLogin === 'function') {
|
|
178
|
-
await onPasskeyLogin();
|
|
179
|
-
}
|
|
180
|
-
} catch (err) {
|
|
181
|
-
setInternalError(err.message || 'Ошибка аутентификации через Passkey');
|
|
182
|
-
} finally {
|
|
183
|
-
setPasskeyInternalLoading(false);
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
if (!pageReady) {
|
|
188
|
-
return (
|
|
189
|
-
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background }}>
|
|
190
|
-
<Spinner size={32} />
|
|
191
|
-
</div>
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const enabledSSO = Array.isArray(ssoProviders) ? ssoProviders : [];
|
|
196
|
-
const hasPasskey = showPasskey && typeof onPasskeyLogin === 'function';
|
|
197
|
-
const hasAuthMethods = showPasswordLogin || hasPasskey || enabledSSO.length > 0;
|
|
198
|
-
|
|
199
|
-
return (
|
|
200
|
-
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background, padding: 16 }}>
|
|
201
|
-
<div style={{ width: '100%', maxWidth: cardMaxWidth }}>
|
|
202
|
-
{/* Header & Branding */}
|
|
203
|
-
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
|
204
|
-
{logo ? (
|
|
205
|
-
<div style={{ height: 54, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' }}>
|
|
206
|
-
{typeof logo === 'string' ? (
|
|
207
|
-
<img src={logo} alt={logoAlt} style={{ maxHeight: 54, maxWidth: 200, objectFit: 'contain', display: 'block' }} />
|
|
208
|
-
) : (
|
|
209
|
-
logo
|
|
210
|
-
)}
|
|
211
|
-
</div>
|
|
212
|
-
) : null}
|
|
213
|
-
|
|
214
|
-
{title && (
|
|
215
|
-
<h1 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--primary-text)', margin: 0 }}>
|
|
216
|
-
{title}
|
|
217
|
-
</h1>
|
|
218
|
-
)}
|
|
219
|
-
|
|
220
|
-
{subtitle && (
|
|
221
|
-
<div style={{ fontSize: '.78rem', color: 'var(--muted)', marginTop: 4 }}>
|
|
222
|
-
{subtitle}
|
|
223
|
-
</div>
|
|
224
|
-
)}
|
|
225
|
-
</div>
|
|
226
|
-
|
|
227
|
-
{headerSlot}
|
|
228
|
-
|
|
229
|
-
{/* Notices */}
|
|
230
|
-
{notice && (
|
|
231
|
-
<div style={{ fontSize: '.78rem', color: 'var(--accent)', background: 'var(--accent-subtle, rgba(0,0,0,0.04))', padding: '8px 12px', borderRadius: 'var(--rads)', marginBottom: 14 }}>
|
|
232
|
-
{notice}
|
|
233
|
-
</div>
|
|
234
|
-
)}
|
|
235
|
-
|
|
236
|
-
{/* Errors */}
|
|
237
|
-
{error && (
|
|
238
|
-
<div style={{ fontSize: '.78rem', color: 'var(--danger)', background: 'var(--danger-bg)', padding: '8px 12px', borderRadius: 'var(--rads)', marginBottom: 14 }}>
|
|
239
|
-
{error}
|
|
240
|
-
</div>
|
|
241
|
-
)}
|
|
242
|
-
|
|
243
|
-
{/* Password Login Form */}
|
|
244
|
-
{showPasswordLogin && (
|
|
245
|
-
<div
|
|
246
|
-
onKeyDown={(e) => {
|
|
247
|
-
if (e.key === 'Enter') {
|
|
248
|
-
e.preventDefault();
|
|
249
|
-
handleSubmit();
|
|
250
|
-
}
|
|
251
|
-
}}
|
|
252
|
-
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
|
|
253
|
-
>
|
|
254
|
-
<Input
|
|
255
|
-
label={usernameLabel}
|
|
256
|
-
size="lg"
|
|
257
|
-
value={username}
|
|
258
|
-
onChange={(e) => setUsername(e.target.value)}
|
|
259
|
-
placeholder={usernamePlaceholder}
|
|
260
|
-
name="login_auth_user"
|
|
261
|
-
id="login_auth_user"
|
|
262
|
-
autoComplete="off"
|
|
263
|
-
autoCorrect="off"
|
|
264
|
-
autoCapitalize="off"
|
|
265
|
-
spellCheck="false"
|
|
266
|
-
data-lpignore="true"
|
|
267
|
-
data-1p-ignore="true"
|
|
268
|
-
data-form-type="other"
|
|
269
|
-
data-bwignore="true"
|
|
270
|
-
required
|
|
271
|
-
/>
|
|
272
|
-
<Input
|
|
273
|
-
label={passwordLabel}
|
|
274
|
-
size="lg"
|
|
275
|
-
type="passwd"
|
|
276
|
-
name="login_auth_secret"
|
|
277
|
-
id="login_auth_secret"
|
|
278
|
-
autoComplete="off"
|
|
279
|
-
autoCorrect="off"
|
|
280
|
-
autoCapitalize="off"
|
|
281
|
-
spellCheck="false"
|
|
282
|
-
data-lpignore="true"
|
|
283
|
-
data-1p-ignore="true"
|
|
284
|
-
data-form-type="other"
|
|
285
|
-
data-bwignore="true"
|
|
286
|
-
value={password}
|
|
287
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
288
|
-
placeholder={passwordPlaceholder}
|
|
289
|
-
required
|
|
290
|
-
/>
|
|
291
|
-
|
|
292
|
-
{/* Captcha Slot */}
|
|
293
|
-
{isCaptchaEnabled && (
|
|
294
|
-
<div style={{ display: 'flex', justifyContent: 'center', margin: '2px 0' }}>
|
|
295
|
-
{captchaProvider === 'cap' ? (
|
|
296
|
-
<cap-widget
|
|
297
|
-
ref={capRef}
|
|
298
|
-
data-cap-api-endpoint={captcha?.endpoint || '/api/captcha/'}
|
|
299
|
-
></cap-widget>
|
|
300
|
-
) : typeof captcha?.render === 'function' ? (
|
|
301
|
-
captcha.render(setCapToken, () => setCapToken(''), capRef)
|
|
302
|
-
) : null}
|
|
303
|
-
</div>
|
|
304
|
-
)}
|
|
305
|
-
|
|
306
|
-
{extraFields}
|
|
307
|
-
|
|
308
|
-
<Button
|
|
309
|
-
type="button"
|
|
310
|
-
variant="primary"
|
|
311
|
-
size="lg"
|
|
312
|
-
loading={loading}
|
|
313
|
-
onClick={handleSubmit}
|
|
314
|
-
style={{
|
|
315
|
-
width: '100%',
|
|
316
|
-
marginTop: 14,
|
|
317
|
-
color: '#ffffff',
|
|
318
|
-
background: 'var(--accent)',
|
|
319
|
-
fontWeight: 600,
|
|
320
|
-
}}
|
|
321
|
-
>
|
|
322
|
-
{loading ? submitButtonLoadingText : submitButtonText}
|
|
323
|
-
</Button>
|
|
324
|
-
</div>
|
|
325
|
-
)}
|
|
326
|
-
|
|
327
|
-
{/* Passkey Biometric Action */}
|
|
328
|
-
{hasPasskey && (
|
|
329
|
-
<div style={{ marginTop: showPasswordLogin ? 10 : 0 }}>
|
|
330
|
-
<Button
|
|
331
|
-
type="button"
|
|
332
|
-
variant="default"
|
|
333
|
-
size="lg"
|
|
334
|
-
loading={isPasskeyBusy}
|
|
335
|
-
onClick={handlePasskeyClick}
|
|
336
|
-
style={{
|
|
337
|
-
width: '100%',
|
|
338
|
-
color: 'var(--primary-text)',
|
|
339
|
-
background: 'var(--surface)',
|
|
340
|
-
border: '1px solid var(--border)',
|
|
341
|
-
fontWeight: 600,
|
|
342
|
-
fontSize: '.85rem',
|
|
343
|
-
}}
|
|
344
|
-
icon={passkeyIcon || <Fingerprint size={16} />}
|
|
345
|
-
>
|
|
346
|
-
{passkeyButtonText}
|
|
347
|
-
</Button>
|
|
348
|
-
</div>
|
|
349
|
-
)}
|
|
350
|
-
|
|
351
|
-
{/* SSO Divider */}
|
|
352
|
-
{(showPasswordLogin || hasPasskey) && enabledSSO.length > 0 && (
|
|
353
|
-
<Divider color="var(--border)" size="md" style={{ margin: '18px 0' }}>
|
|
354
|
-
{ssoDividerText}
|
|
355
|
-
</Divider>
|
|
356
|
-
)}
|
|
357
|
-
|
|
358
|
-
{/* SSO Providers */}
|
|
359
|
-
{enabledSSO.length > 0 && (
|
|
360
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
361
|
-
{enabledSSO.map((p) => {
|
|
362
|
-
const buttonNode = (
|
|
363
|
-
<Button
|
|
364
|
-
variant={p.variant || 'default'}
|
|
365
|
-
size="lg"
|
|
366
|
-
onClick={p.onClick}
|
|
367
|
-
style={{
|
|
368
|
-
width: '100%',
|
|
369
|
-
color: 'var(--primary-text)',
|
|
370
|
-
background: 'var(--surface)',
|
|
371
|
-
border: '1px solid var(--border)',
|
|
372
|
-
fontWeight: 600,
|
|
373
|
-
fontSize: '.85rem',
|
|
374
|
-
}}
|
|
375
|
-
icon={p.icon || <Lock size={15} />}
|
|
376
|
-
>
|
|
377
|
-
Войти через {p.name}
|
|
378
|
-
</Button>
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
if (p.url) {
|
|
382
|
-
return (
|
|
383
|
-
<a key={p.id} href={p.url} style={{ textDecoration: 'none', display: 'block' }}>
|
|
384
|
-
{buttonNode}
|
|
385
|
-
</a>
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
return <div key={p.id}>{buttonNode}</div>;
|
|
390
|
-
})}
|
|
391
|
-
</div>
|
|
392
|
-
)}
|
|
393
|
-
|
|
394
|
-
{/* Fallback when no auth methods available */}
|
|
395
|
-
{!hasAuthMethods && (
|
|
396
|
-
<div style={{ textAlign: 'center', color: 'var(--muted)', fontSize: '.78rem', padding: '20px 0' }}>
|
|
397
|
-
{noAuthMethodsText}
|
|
398
|
-
</div>
|
|
399
|
-
)}
|
|
400
|
-
|
|
401
|
-
{footerSlot}
|
|
402
|
-
</div>
|
|
403
|
-
</div>
|
|
404
|
-
);
|
|
405
|
-
}
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Fingerprint, Lock } from 'lucide-react';
|
|
3
|
+
import Button from './Button.jsx';
|
|
4
|
+
import Input from './Input.jsx';
|
|
5
|
+
import Spinner from './Spinner.jsx';
|
|
6
|
+
import Divider from './Divider.jsx';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Universal White-Label LoginPage component for cynx-lib.
|
|
10
|
+
* Supports Password login, Multi-Provider Captcha (Cap, Turnstile, reCAPTCHA, custom),
|
|
11
|
+
* Passkeys (WebAuthn / Biometrics), and SSO providers.
|
|
12
|
+
* STRICT NO EMOJIS!
|
|
13
|
+
*/
|
|
14
|
+
export default function LoginPage({
|
|
15
|
+
logo,
|
|
16
|
+
logoAlt = 'Logo',
|
|
17
|
+
title = 'Вход в систему',
|
|
18
|
+
subtitle,
|
|
19
|
+
background = 'var(--grey-1, var(--bg, #f4f5f8))',
|
|
20
|
+
cardMaxWidth = 330,
|
|
21
|
+
|
|
22
|
+
// Password Login
|
|
23
|
+
showPasswordLogin = true,
|
|
24
|
+
usernameLabel = 'Логин',
|
|
25
|
+
passwordLabel = 'Пароль',
|
|
26
|
+
usernamePlaceholder = 'Введите логин',
|
|
27
|
+
passwordPlaceholder = 'Введите пароль',
|
|
28
|
+
submitButtonText = 'Войти',
|
|
29
|
+
submitButtonLoadingText = 'Вход...',
|
|
30
|
+
onSubmit,
|
|
31
|
+
|
|
32
|
+
// Passkey Login
|
|
33
|
+
showPasskey = false,
|
|
34
|
+
passkeyButtonText = 'Войти через Passkey',
|
|
35
|
+
passkeyIcon,
|
|
36
|
+
onPasskeyLogin,
|
|
37
|
+
passkeyLoading = false,
|
|
38
|
+
|
|
39
|
+
// SSO Providers
|
|
40
|
+
ssoProviders = [],
|
|
41
|
+
ssoDividerText,
|
|
42
|
+
|
|
43
|
+
// Captcha Configuration
|
|
44
|
+
captcha,
|
|
45
|
+
|
|
46
|
+
// Errors, Notices & States
|
|
47
|
+
error: externalError,
|
|
48
|
+
notice,
|
|
49
|
+
loading: externalLoading = false,
|
|
50
|
+
|
|
51
|
+
// Custom Slots
|
|
52
|
+
headerSlot,
|
|
53
|
+
footerSlot,
|
|
54
|
+
extraFields,
|
|
55
|
+
noAuthMethodsText = 'Нет доступных способов входа. Обратитесь к администратору.',
|
|
56
|
+
}) {
|
|
57
|
+
const [username, setUsername] = useState('');
|
|
58
|
+
const [password, setPassword] = useState('');
|
|
59
|
+
const [internalError, setInternalError] = useState('');
|
|
60
|
+
const [internalLoading, setInternalLoading] = useState(false);
|
|
61
|
+
const [pageReady, setPageReady] = useState(false);
|
|
62
|
+
const [capToken, setCapToken] = useState('');
|
|
63
|
+
const [passkeyInternalLoading, setPasskeyInternalLoading] = useState(false);
|
|
64
|
+
const capRef = useRef(null);
|
|
65
|
+
|
|
66
|
+
const error = externalError !== undefined ? externalError : internalError;
|
|
67
|
+
const loading = externalLoading || internalLoading;
|
|
68
|
+
const isPasskeyBusy = passkeyLoading || passkeyInternalLoading;
|
|
69
|
+
|
|
70
|
+
const isCaptchaEnabled = !!(captcha && (captcha.enabled !== false && (captcha.provider || captcha.endpoint || captcha.siteKey)));
|
|
71
|
+
const captchaProvider = captcha?.provider || 'cap';
|
|
72
|
+
|
|
73
|
+
// 1. Setup Captcha Event Listeners (Cap Anti-Bot Widget)
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!isCaptchaEnabled || captchaProvider !== 'cap') return;
|
|
76
|
+
const el = capRef.current;
|
|
77
|
+
if (!el) return;
|
|
78
|
+
|
|
79
|
+
const handleSolve = (e) => {
|
|
80
|
+
setCapToken(e.detail?.token || '');
|
|
81
|
+
};
|
|
82
|
+
const handleReset = () => {
|
|
83
|
+
setCapToken('');
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
el.addEventListener('solve', handleSolve);
|
|
87
|
+
el.addEventListener('reset', handleReset);
|
|
88
|
+
return () => {
|
|
89
|
+
el.removeEventListener('solve', handleSolve);
|
|
90
|
+
el.removeEventListener('reset', handleReset);
|
|
91
|
+
};
|
|
92
|
+
}, [pageReady, showPasswordLogin, isCaptchaEnabled, captchaProvider]);
|
|
93
|
+
|
|
94
|
+
// 2. Preload Fonts & Logo to prevent Layout Shifts (FOUC)
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
let isMounted = true;
|
|
97
|
+
|
|
98
|
+
const prepareAssets = async () => {
|
|
99
|
+
const tasks = [];
|
|
100
|
+
|
|
101
|
+
if (typeof document !== 'undefined' && document.fonts && typeof document.fonts.load === 'function') {
|
|
102
|
+
tasks.push(document.fonts.load('13px Manrope').catch(() => {}));
|
|
103
|
+
tasks.push(document.fonts.load('600 11px Manrope').catch(() => {}));
|
|
104
|
+
tasks.push(document.fonts.load('700 14px Manrope').catch(() => {}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof logo === 'string' && logo.trim()) {
|
|
108
|
+
const logoPromise = new Promise((resolve) => {
|
|
109
|
+
const img = new Image();
|
|
110
|
+
img.onload = resolve;
|
|
111
|
+
img.onerror = resolve;
|
|
112
|
+
img.src = logo;
|
|
113
|
+
if (typeof img.decode === 'function') {
|
|
114
|
+
img.decode().then(resolve).catch(resolve);
|
|
115
|
+
}
|
|
116
|
+
setTimeout(resolve, 300);
|
|
117
|
+
});
|
|
118
|
+
tasks.push(logoPromise);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await Promise.all(tasks);
|
|
122
|
+
if (isMounted) {
|
|
123
|
+
setPageReady(true);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
prepareAssets();
|
|
128
|
+
|
|
129
|
+
return () => {
|
|
130
|
+
isMounted = false;
|
|
131
|
+
};
|
|
132
|
+
}, [logo]);
|
|
133
|
+
|
|
134
|
+
// 3. Form Submission
|
|
135
|
+
const handleSubmit = async (e) => {
|
|
136
|
+
if (e && e.preventDefault) e.preventDefault();
|
|
137
|
+
if (loading) return;
|
|
138
|
+
|
|
139
|
+
if (!username.trim() || !password) {
|
|
140
|
+
setInternalError('Введите логин и пароль');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (isCaptchaEnabled && !capToken) {
|
|
145
|
+
setInternalError('Пожалуйста, подтвердите капчу');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setInternalError('');
|
|
150
|
+
setInternalLoading(true);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (typeof onSubmit === 'function') {
|
|
154
|
+
await onSubmit({
|
|
155
|
+
username: username.trim(),
|
|
156
|
+
password,
|
|
157
|
+
captchaToken: isCaptchaEnabled ? capToken : undefined,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
setInternalError(err.message || 'Ошибка авторизации');
|
|
162
|
+
if (capRef.current && typeof capRef.current.reset === 'function') {
|
|
163
|
+
capRef.current.reset();
|
|
164
|
+
}
|
|
165
|
+
setCapToken('');
|
|
166
|
+
} finally {
|
|
167
|
+
setInternalLoading(false);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// 4. Passkey Action Handler
|
|
172
|
+
const handlePasskeyClick = async () => {
|
|
173
|
+
if (isPasskeyBusy) return;
|
|
174
|
+
setInternalError('');
|
|
175
|
+
setPasskeyInternalLoading(true);
|
|
176
|
+
try {
|
|
177
|
+
if (typeof onPasskeyLogin === 'function') {
|
|
178
|
+
await onPasskeyLogin();
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
setInternalError(err.message || 'Ошибка аутентификации через Passkey');
|
|
182
|
+
} finally {
|
|
183
|
+
setPasskeyInternalLoading(false);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (!pageReady) {
|
|
188
|
+
return (
|
|
189
|
+
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background }}>
|
|
190
|
+
<Spinner size={32} />
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const enabledSSO = Array.isArray(ssoProviders) ? ssoProviders : [];
|
|
196
|
+
const hasPasskey = showPasskey && typeof onPasskeyLogin === 'function';
|
|
197
|
+
const hasAuthMethods = showPasswordLogin || hasPasskey || enabledSSO.length > 0;
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background, padding: 16 }}>
|
|
201
|
+
<div style={{ width: '100%', maxWidth: cardMaxWidth }}>
|
|
202
|
+
{/* Header & Branding */}
|
|
203
|
+
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
|
204
|
+
{logo ? (
|
|
205
|
+
<div style={{ height: 54, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' }}>
|
|
206
|
+
{typeof logo === 'string' ? (
|
|
207
|
+
<img src={logo} alt={logoAlt} style={{ maxHeight: 54, maxWidth: 200, objectFit: 'contain', display: 'block' }} />
|
|
208
|
+
) : (
|
|
209
|
+
logo
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
) : null}
|
|
213
|
+
|
|
214
|
+
{title && (
|
|
215
|
+
<h1 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--primary-text)', margin: 0 }}>
|
|
216
|
+
{title}
|
|
217
|
+
</h1>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{subtitle && (
|
|
221
|
+
<div style={{ fontSize: '.78rem', color: 'var(--muted)', marginTop: 4 }}>
|
|
222
|
+
{subtitle}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{headerSlot}
|
|
228
|
+
|
|
229
|
+
{/* Notices */}
|
|
230
|
+
{notice && (
|
|
231
|
+
<div style={{ fontSize: '.78rem', color: 'var(--accent)', background: 'var(--accent-subtle, rgba(0,0,0,0.04))', padding: '8px 12px', borderRadius: 'var(--rads)', marginBottom: 14 }}>
|
|
232
|
+
{notice}
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Errors */}
|
|
237
|
+
{error && (
|
|
238
|
+
<div style={{ fontSize: '.78rem', color: 'var(--danger)', background: 'var(--danger-bg)', padding: '8px 12px', borderRadius: 'var(--rads)', marginBottom: 14 }}>
|
|
239
|
+
{error}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Password Login Form */}
|
|
244
|
+
{showPasswordLogin && (
|
|
245
|
+
<div
|
|
246
|
+
onKeyDown={(e) => {
|
|
247
|
+
if (e.key === 'Enter') {
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
handleSubmit();
|
|
250
|
+
}
|
|
251
|
+
}}
|
|
252
|
+
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
|
|
253
|
+
>
|
|
254
|
+
<Input
|
|
255
|
+
label={usernameLabel}
|
|
256
|
+
size="lg"
|
|
257
|
+
value={username}
|
|
258
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
259
|
+
placeholder={usernamePlaceholder}
|
|
260
|
+
name="login_auth_user"
|
|
261
|
+
id="login_auth_user"
|
|
262
|
+
autoComplete="off"
|
|
263
|
+
autoCorrect="off"
|
|
264
|
+
autoCapitalize="off"
|
|
265
|
+
spellCheck="false"
|
|
266
|
+
data-lpignore="true"
|
|
267
|
+
data-1p-ignore="true"
|
|
268
|
+
data-form-type="other"
|
|
269
|
+
data-bwignore="true"
|
|
270
|
+
required
|
|
271
|
+
/>
|
|
272
|
+
<Input
|
|
273
|
+
label={passwordLabel}
|
|
274
|
+
size="lg"
|
|
275
|
+
type="passwd"
|
|
276
|
+
name="login_auth_secret"
|
|
277
|
+
id="login_auth_secret"
|
|
278
|
+
autoComplete="off"
|
|
279
|
+
autoCorrect="off"
|
|
280
|
+
autoCapitalize="off"
|
|
281
|
+
spellCheck="false"
|
|
282
|
+
data-lpignore="true"
|
|
283
|
+
data-1p-ignore="true"
|
|
284
|
+
data-form-type="other"
|
|
285
|
+
data-bwignore="true"
|
|
286
|
+
value={password}
|
|
287
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
288
|
+
placeholder={passwordPlaceholder}
|
|
289
|
+
required
|
|
290
|
+
/>
|
|
291
|
+
|
|
292
|
+
{/* Captcha Slot */}
|
|
293
|
+
{isCaptchaEnabled && (
|
|
294
|
+
<div style={{ display: 'flex', justifyContent: 'center', margin: '2px 0' }}>
|
|
295
|
+
{captchaProvider === 'cap' ? (
|
|
296
|
+
<cap-widget
|
|
297
|
+
ref={capRef}
|
|
298
|
+
data-cap-api-endpoint={captcha?.endpoint || '/api/captcha/'}
|
|
299
|
+
></cap-widget>
|
|
300
|
+
) : typeof captcha?.render === 'function' ? (
|
|
301
|
+
captcha.render(setCapToken, () => setCapToken(''), capRef)
|
|
302
|
+
) : null}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{extraFields}
|
|
307
|
+
|
|
308
|
+
<Button
|
|
309
|
+
type="button"
|
|
310
|
+
variant="primary"
|
|
311
|
+
size="lg"
|
|
312
|
+
loading={loading}
|
|
313
|
+
onClick={handleSubmit}
|
|
314
|
+
style={{
|
|
315
|
+
width: '100%',
|
|
316
|
+
marginTop: 14,
|
|
317
|
+
color: '#ffffff',
|
|
318
|
+
background: 'var(--accent)',
|
|
319
|
+
fontWeight: 600,
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
{loading ? submitButtonLoadingText : submitButtonText}
|
|
323
|
+
</Button>
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
|
|
327
|
+
{/* Passkey Biometric Action */}
|
|
328
|
+
{hasPasskey && (
|
|
329
|
+
<div style={{ marginTop: showPasswordLogin ? 10 : 0 }}>
|
|
330
|
+
<Button
|
|
331
|
+
type="button"
|
|
332
|
+
variant="default"
|
|
333
|
+
size="lg"
|
|
334
|
+
loading={isPasskeyBusy}
|
|
335
|
+
onClick={handlePasskeyClick}
|
|
336
|
+
style={{
|
|
337
|
+
width: '100%',
|
|
338
|
+
color: 'var(--primary-text)',
|
|
339
|
+
background: 'var(--surface)',
|
|
340
|
+
border: '1px solid var(--border)',
|
|
341
|
+
fontWeight: 600,
|
|
342
|
+
fontSize: '.85rem',
|
|
343
|
+
}}
|
|
344
|
+
icon={passkeyIcon || <Fingerprint size={16} />}
|
|
345
|
+
>
|
|
346
|
+
{passkeyButtonText}
|
|
347
|
+
</Button>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{/* SSO Divider */}
|
|
352
|
+
{(showPasswordLogin || hasPasskey) && enabledSSO.length > 0 && (
|
|
353
|
+
<Divider color="var(--border)" size="md" style={{ margin: '18px 0' }}>
|
|
354
|
+
{ssoDividerText}
|
|
355
|
+
</Divider>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{/* SSO Providers */}
|
|
359
|
+
{enabledSSO.length > 0 && (
|
|
360
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
361
|
+
{enabledSSO.map((p) => {
|
|
362
|
+
const buttonNode = (
|
|
363
|
+
<Button
|
|
364
|
+
variant={p.variant || 'default'}
|
|
365
|
+
size="lg"
|
|
366
|
+
onClick={p.onClick}
|
|
367
|
+
style={{
|
|
368
|
+
width: '100%',
|
|
369
|
+
color: 'var(--primary-text)',
|
|
370
|
+
background: 'var(--surface)',
|
|
371
|
+
border: '1px solid var(--border)',
|
|
372
|
+
fontWeight: 600,
|
|
373
|
+
fontSize: '.85rem',
|
|
374
|
+
}}
|
|
375
|
+
icon={p.icon || <Lock size={15} />}
|
|
376
|
+
>
|
|
377
|
+
Войти через {p.name}
|
|
378
|
+
</Button>
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
if (p.url) {
|
|
382
|
+
return (
|
|
383
|
+
<a key={p.id} href={p.url} style={{ textDecoration: 'none', display: 'block' }}>
|
|
384
|
+
{buttonNode}
|
|
385
|
+
</a>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return <div key={p.id}>{buttonNode}</div>;
|
|
390
|
+
})}
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
{/* Fallback when no auth methods available */}
|
|
395
|
+
{!hasAuthMethods && (
|
|
396
|
+
<div style={{ textAlign: 'center', color: 'var(--muted)', fontSize: '.78rem', padding: '20px 0' }}>
|
|
397
|
+
{noAuthMethodsText}
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
400
|
+
|
|
401
|
+
{footerSlot}
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|