cynx-lib 1.3.47 → 1.3.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cynx-lib",
3
- "version": "1.3.47",
3
+ "version": "1.3.49",
4
4
  "type": "module",
5
5
  "main": "./index.cjs",
6
6
  "module": "./index.js",
@@ -120,11 +120,11 @@ export function ActionsMenu({ items, align = 'right' }) {
120
120
  display: 'flex', alignItems: 'center', gap: 8,
121
121
  padding: '6px 10px', borderRadius: 'var(--rads,8px)',
122
122
  fontSize: 12, cursor: 'pointer', transition: 'all .14s',
123
- color: item.variant === 'danger' ? 'var(--danger, #f64747)' : item.variant === 'success' ? 'var(--success, #27ae60)' : item.variant === 'warning' ? 'var(--accent, #f99e2c)' : 'var(--text,#1a1a2e)',
123
+ color: (item.variant === 'danger' || item.danger) ? 'var(--danger, #ef4444)' : item.variant === 'success' ? 'var(--success, #16a34a)' : item.variant === 'warning' ? 'var(--warning, #f59e0b)' : 'var(--primary-text, #18181b)',
124
124
  fontWeight: 500,
125
125
  }}
126
126
  onMouseEnter={e => {
127
- e.currentTarget.style.background = item.variant === 'danger' ? 'var(--dd, rgba(246,71,71,.08))' : item.variant === 'warning' ? 'var(--pd, rgba(249,158,44,.08))' : 'var(--grey-1,#f5f5f8)';
127
+ e.currentTarget.style.background = (item.variant === 'danger' || item.danger) ? 'var(--danger-bg, rgba(239,68,68,.10))' : item.variant === 'success' ? 'var(--success-bg, rgba(22,163,74,.10))' : item.variant === 'warning' ? 'var(--warning-bg, rgba(245,158,11,.12))' : 'var(--grey-1, #f4f4f5)';
128
128
  }}
129
129
  onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
130
130
  >
@@ -0,0 +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
+ }
package/ui/index.cjs CHANGED
@@ -49,6 +49,7 @@ __export(index_exports, {
49
49
  Input: () => Input,
50
50
  Layout: () => Layout,
51
51
  LinearProgress: () => LinearProgress,
52
+ LoginPage: () => LoginPage,
52
53
  Modal: () => Modal,
53
54
  PageShell: () => PageShell,
54
55
  Pagination: () => Pagination,
@@ -125,7 +126,9 @@ var import_FilePicker = __toESM(require("./components/FilePicker.jsx"), 1);
125
126
  var import_Divider = __toESM(require("./components/Divider.jsx"), 1);
126
127
  var import_InfoBadge = __toESM(require("./components/InfoBadge.jsx"), 1);
127
128
  var import_Layout = __toESM(require("./components/Layout.jsx"), 1);
129
+ var import_LoginPage = __toESM(require("./components/LoginPage.jsx"), 1);
128
130
  const Layout = import_Layout.default;
131
+ const LoginPage = import_LoginPage.default;
129
132
  const Button = import_Button.default;
130
133
  const FilePicker = import_FilePicker.default;
131
134
  const Divider = import_Divider.default;
package/ui/index.js CHANGED
@@ -38,8 +38,10 @@ import { default as _FilePicker } from './components/FilePicker.jsx';
38
38
  import { default as _Divider } from './components/Divider.jsx';
39
39
  import { default as _InfoBadge } from './components/InfoBadge.jsx';
40
40
  import { default as _Layout } from './components/Layout.jsx';
41
+ import { default as _LoginPage } from './components/LoginPage.jsx';
41
42
 
42
43
  export const Layout = _Layout;
44
+ export const LoginPage = _LoginPage;
43
45
  export const Button = _Button;
44
46
  export const FilePicker = _FilePicker;
45
47
  export const Divider = _Divider;