cynx-lib 1.3.81 → 1.3.83
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
|
@@ -35,10 +35,15 @@ export default function LoginPage({
|
|
|
35
35
|
passkeyIcon,
|
|
36
36
|
onPasskeyLogin,
|
|
37
37
|
passkeyLoading = false,
|
|
38
|
+
passkeyInGrid = false,
|
|
39
|
+
passkeyPosition = 'start', // 'start' | 'end'
|
|
38
40
|
|
|
39
|
-
// SSO Providers
|
|
41
|
+
// SSO & Alternate Providers
|
|
40
42
|
ssoProviders = [],
|
|
41
|
-
ssoDividerText,
|
|
43
|
+
ssoDividerText = 'или',
|
|
44
|
+
ssoLayout = 'column', // 'column' | 'grid'
|
|
45
|
+
ssoColumns, // e.g. 2, 3, 4, or undefined (auto)
|
|
46
|
+
ssoButtonFormat, // 'full' | 'short' | 'icon-only'
|
|
42
47
|
|
|
43
48
|
// Captcha Configuration
|
|
44
49
|
captcha,
|
|
@@ -61,11 +66,13 @@ export default function LoginPage({
|
|
|
61
66
|
const [pageReady, setPageReady] = useState(false);
|
|
62
67
|
const [capToken, setCapToken] = useState('');
|
|
63
68
|
const [passkeyInternalLoading, setPasskeyInternalLoading] = useState(false);
|
|
69
|
+
const [activeSsoId, setActiveSsoId] = useState(null);
|
|
64
70
|
const capRef = useRef(null);
|
|
65
71
|
|
|
66
72
|
const error = externalError !== undefined ? externalError : internalError;
|
|
67
|
-
const
|
|
73
|
+
const isFormBusy = externalLoading || internalLoading;
|
|
68
74
|
const isPasskeyBusy = passkeyLoading || passkeyInternalLoading;
|
|
75
|
+
const isAnyBusy = isFormBusy || isPasskeyBusy || !!activeSsoId;
|
|
69
76
|
|
|
70
77
|
const isCaptchaEnabled = !!(captcha && (captcha.enabled !== false && (captcha.provider || captcha.endpoint || captcha.siteKey)));
|
|
71
78
|
const captchaProvider = captcha?.provider || 'cap';
|
|
@@ -134,7 +141,7 @@ export default function LoginPage({
|
|
|
134
141
|
// 3. Form Submission
|
|
135
142
|
const handleSubmit = async (e) => {
|
|
136
143
|
if (e && e.preventDefault) e.preventDefault();
|
|
137
|
-
if (
|
|
144
|
+
if (isAnyBusy) return;
|
|
138
145
|
|
|
139
146
|
if (!username.trim() || !password) {
|
|
140
147
|
setInternalError('Введите логин и пароль');
|
|
@@ -170,7 +177,7 @@ export default function LoginPage({
|
|
|
170
177
|
|
|
171
178
|
// 4. Passkey Action Handler
|
|
172
179
|
const handlePasskeyClick = async () => {
|
|
173
|
-
if (
|
|
180
|
+
if (isAnyBusy) return;
|
|
174
181
|
setInternalError('');
|
|
175
182
|
setPasskeyInternalLoading(true);
|
|
176
183
|
try {
|
|
@@ -184,6 +191,21 @@ export default function LoginPage({
|
|
|
184
191
|
}
|
|
185
192
|
};
|
|
186
193
|
|
|
194
|
+
// 5. SSO Action Handler (with independent button loading)
|
|
195
|
+
const handleSsoClick = async (p, e) => {
|
|
196
|
+
if (isAnyBusy) return;
|
|
197
|
+
if (typeof p.onClick !== 'function') return;
|
|
198
|
+
setInternalError('');
|
|
199
|
+
setActiveSsoId(p.id);
|
|
200
|
+
try {
|
|
201
|
+
await p.onClick(e);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
setInternalError(err.message || `Ошибка входа через ${p.name || 'провайдера'}`);
|
|
204
|
+
} finally {
|
|
205
|
+
setActiveSsoId(null);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
187
209
|
if (!pageReady) {
|
|
188
210
|
return (
|
|
189
211
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background }}>
|
|
@@ -196,6 +218,29 @@ export default function LoginPage({
|
|
|
196
218
|
const hasPasskey = showPasskey && typeof onPasskeyLogin === 'function';
|
|
197
219
|
const hasAuthMethods = showPasswordLogin || hasPasskey || enabledSSO.length > 0;
|
|
198
220
|
|
|
221
|
+
const isGrid = ssoLayout === 'grid' || (typeof ssoColumns === 'number' && ssoColumns > 1);
|
|
222
|
+
const shouldPutPasskeyInGrid = hasPasskey && (passkeyInGrid || (isGrid && passkeyInGrid !== false && enabledSSO.length > 0));
|
|
223
|
+
|
|
224
|
+
const passkeyProvider = hasPasskey ? {
|
|
225
|
+
id: '__passkey__',
|
|
226
|
+
name: passkeyButtonText,
|
|
227
|
+
label: passkeyButtonText,
|
|
228
|
+
icon: passkeyIcon || <Fingerprint size={16} />,
|
|
229
|
+
onClick: handlePasskeyClick,
|
|
230
|
+
loading: isPasskeyBusy,
|
|
231
|
+
disabled: isAnyBusy && !isPasskeyBusy,
|
|
232
|
+
isPasskey: true,
|
|
233
|
+
} : null;
|
|
234
|
+
|
|
235
|
+
const allAlternateProviders = shouldPutPasskeyInGrid
|
|
236
|
+
? (passkeyPosition === 'end'
|
|
237
|
+
? [...enabledSSO, passkeyProvider]
|
|
238
|
+
: [passkeyProvider, ...enabledSSO]
|
|
239
|
+
)
|
|
240
|
+
: enabledSSO;
|
|
241
|
+
|
|
242
|
+
const hasAlternateMethods = (hasPasskey && !shouldPutPasskeyInGrid) || allAlternateProviders.length > 0;
|
|
243
|
+
|
|
199
244
|
return (
|
|
200
245
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background, padding: 16 }}>
|
|
201
246
|
<div style={{ width: '100%', maxWidth: cardMaxWidth }}>
|
|
@@ -309,7 +354,8 @@ export default function LoginPage({
|
|
|
309
354
|
type="button"
|
|
310
355
|
variant="primary"
|
|
311
356
|
size="lg"
|
|
312
|
-
loading={
|
|
357
|
+
loading={isFormBusy}
|
|
358
|
+
disabled={isAnyBusy && !isFormBusy}
|
|
313
359
|
onClick={handleSubmit}
|
|
314
360
|
style={{
|
|
315
361
|
width: '100%',
|
|
@@ -319,19 +365,27 @@ export default function LoginPage({
|
|
|
319
365
|
fontWeight: 600,
|
|
320
366
|
}}
|
|
321
367
|
>
|
|
322
|
-
{
|
|
368
|
+
{isFormBusy ? submitButtonLoadingText : submitButtonText}
|
|
323
369
|
</Button>
|
|
324
370
|
</div>
|
|
325
371
|
)}
|
|
326
372
|
|
|
327
|
-
{/*
|
|
328
|
-
{
|
|
329
|
-
<
|
|
373
|
+
{/* Divider between password login and alternate auth methods */}
|
|
374
|
+
{showPasswordLogin && hasAlternateMethods && (
|
|
375
|
+
<Divider color="var(--border)" size="md" style={{ margin: '18px 0' }}>
|
|
376
|
+
{ssoDividerText}
|
|
377
|
+
</Divider>
|
|
378
|
+
)}
|
|
379
|
+
|
|
380
|
+
{/* Standalone Passkey Action (when not placed in grid) */}
|
|
381
|
+
{hasPasskey && !shouldPutPasskeyInGrid && (
|
|
382
|
+
<div style={{ marginBottom: allAlternateProviders.length > 0 ? 8 : 0 }}>
|
|
330
383
|
<Button
|
|
331
384
|
type="button"
|
|
332
385
|
variant="default"
|
|
333
386
|
size="lg"
|
|
334
387
|
loading={isPasskeyBusy}
|
|
388
|
+
disabled={isAnyBusy && !isPasskeyBusy}
|
|
335
389
|
onClick={handlePasskeyClick}
|
|
336
390
|
style={{
|
|
337
391
|
width: '100%',
|
|
@@ -348,45 +402,107 @@ export default function LoginPage({
|
|
|
348
402
|
</div>
|
|
349
403
|
)}
|
|
350
404
|
|
|
351
|
-
{/* SSO
|
|
352
|
-
{
|
|
353
|
-
<
|
|
354
|
-
{
|
|
355
|
-
|
|
356
|
-
|
|
405
|
+
{/* Alternate Auth Providers (SSO, Social, Passkey in grid) */}
|
|
406
|
+
{allAlternateProviders.length > 0 && (
|
|
407
|
+
<div
|
|
408
|
+
style={isGrid ? {
|
|
409
|
+
display: 'grid',
|
|
410
|
+
gridTemplateColumns: typeof ssoColumns === 'number'
|
|
411
|
+
? `repeat(${ssoColumns}, minmax(0, 1fr))`
|
|
412
|
+
: `repeat(auto-fit, minmax(${cardMaxWidth > 360 ? '130px' : '90px'}, 1fr))`,
|
|
413
|
+
gap: 8,
|
|
414
|
+
width: '100%',
|
|
415
|
+
} : {
|
|
416
|
+
display: 'flex',
|
|
417
|
+
flexDirection: 'column',
|
|
418
|
+
gap: 8,
|
|
419
|
+
width: '100%',
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
{allAlternateProviders.map((p) => {
|
|
423
|
+
const iconNode = p.logo ? (
|
|
424
|
+
typeof p.logo === 'string' ? (
|
|
425
|
+
<img
|
|
426
|
+
src={p.logo}
|
|
427
|
+
alt={p.name || 'provider'}
|
|
428
|
+
style={{ width: 16, height: 16, objectFit: 'contain', flexShrink: 0, display: 'block' }}
|
|
429
|
+
/>
|
|
430
|
+
) : (
|
|
431
|
+
p.logo
|
|
432
|
+
)
|
|
433
|
+
) : (
|
|
434
|
+
p.icon || (p.isPasskey ? <Fingerprint size={16} /> : <Lock size={15} />)
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
let labelText = '';
|
|
438
|
+
if (ssoButtonFormat === 'icon-only' || p.iconOnly) {
|
|
439
|
+
labelText = '';
|
|
440
|
+
} else if (p.label) {
|
|
441
|
+
labelText = p.label;
|
|
442
|
+
} else if (p.buttonText) {
|
|
443
|
+
labelText = p.buttonText;
|
|
444
|
+
} else if (isGrid || ssoButtonFormat === 'short') {
|
|
445
|
+
labelText = p.name;
|
|
446
|
+
} else {
|
|
447
|
+
labelText = (p.name && (p.name.startsWith('Войти') || p.name.startsWith('Sign in')))
|
|
448
|
+
? p.name
|
|
449
|
+
: `Войти через ${p.name}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const isItemLoading = p.isPasskey
|
|
453
|
+
? isPasskeyBusy
|
|
454
|
+
: (p.loading !== undefined ? p.loading : activeSsoId === p.id);
|
|
455
|
+
|
|
456
|
+
const isItemDisabled = p.isPasskey
|
|
457
|
+
? (isAnyBusy && !isPasskeyBusy)
|
|
458
|
+
: (p.disabled !== undefined ? p.disabled : (isAnyBusy && activeSsoId !== p.id));
|
|
459
|
+
|
|
460
|
+
const handleClick = p.isPasskey ? p.onClick : (e) => handleSsoClick(p, e);
|
|
357
461
|
|
|
358
|
-
{/* SSO Providers */}
|
|
359
|
-
{enabledSSO.length > 0 && (
|
|
360
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
361
|
-
{enabledSSO.map((p) => {
|
|
362
462
|
const buttonNode = (
|
|
363
463
|
<Button
|
|
464
|
+
type="button"
|
|
364
465
|
variant={p.variant || 'default'}
|
|
365
466
|
size="lg"
|
|
366
|
-
|
|
467
|
+
loading={isItemLoading}
|
|
468
|
+
disabled={isItemDisabled}
|
|
469
|
+
onClick={handleClick}
|
|
367
470
|
style={{
|
|
368
471
|
width: '100%',
|
|
472
|
+
display: 'flex',
|
|
473
|
+
alignItems: 'center',
|
|
474
|
+
justifyContent: 'center',
|
|
475
|
+
gap: 8,
|
|
369
476
|
color: 'var(--primary-text)',
|
|
370
477
|
background: 'var(--surface)',
|
|
371
478
|
border: '1px solid var(--border)',
|
|
372
479
|
fontWeight: 600,
|
|
373
|
-
fontSize: '.85rem',
|
|
480
|
+
fontSize: isGrid ? '.82rem' : '.85rem',
|
|
481
|
+
padding: isGrid ? (labelText ? '0 10px' : '0 6px') : '0 16px',
|
|
482
|
+
textOverflow: 'ellipsis',
|
|
483
|
+
whiteSpace: 'nowrap',
|
|
484
|
+
overflow: 'hidden',
|
|
485
|
+
...p.style,
|
|
374
486
|
}}
|
|
375
|
-
icon={
|
|
487
|
+
icon={iconNode}
|
|
376
488
|
>
|
|
377
|
-
|
|
489
|
+
{labelText}
|
|
378
490
|
</Button>
|
|
379
491
|
);
|
|
380
492
|
|
|
381
493
|
if (p.url) {
|
|
382
494
|
return (
|
|
383
|
-
<a key={p.id} href={p.url} style={{ textDecoration: 'none', display: 'block' }}>
|
|
495
|
+
<a key={p.id} href={p.url} style={{ textDecoration: 'none', display: 'block', minWidth: 0 }}>
|
|
384
496
|
{buttonNode}
|
|
385
497
|
</a>
|
|
386
498
|
);
|
|
387
499
|
}
|
|
388
500
|
|
|
389
|
-
return
|
|
501
|
+
return (
|
|
502
|
+
<div key={p.id} style={{ minWidth: 0 }}>
|
|
503
|
+
{buttonNode}
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
390
506
|
})}
|
|
391
507
|
</div>
|
|
392
508
|
)}
|
|
@@ -274,7 +274,7 @@ class StorageManager {
|
|
|
274
274
|
fileType,
|
|
275
275
|
mimeType,
|
|
276
276
|
fileSize: Number(f.file_size || 0),
|
|
277
|
-
|
|
277
|
+
cloudUrl: f.cloud_url || f.cloudUrl || null,
|
|
278
278
|
viewUrl: `${base}/cdn/${fileId}/view`,
|
|
279
279
|
downloadUrl: `${base}/cdn/${fileId}/download`,
|
|
280
280
|
url: `${base}/cdn/${fileId}`,
|
|
@@ -943,6 +943,12 @@ class StorageManager {
|
|
|
943
943
|
}
|
|
944
944
|
return res.status(404).send("File URL not found");
|
|
945
945
|
}
|
|
946
|
+
if (targetUrl.startsWith("/")) {
|
|
947
|
+
const serverBase = (this.storageServerUrl || process.env?.STORAGE_SERVER_URL || "").replace(/\/+$/, "");
|
|
948
|
+
if (serverBase) {
|
|
949
|
+
targetUrl = `${serverBase}${targetUrl}`;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
946
952
|
if (!/^https?:\/\//i.test(targetUrl)) {
|
|
947
953
|
targetUrl = `https://${targetUrl}`;
|
|
948
954
|
}
|
|
@@ -1067,10 +1073,30 @@ class StorageManager {
|
|
|
1067
1073
|
const fileType = rf.fileType || rf.file_type || (0, import_storage.getFileTypeCategory)(mimeType, rf.originalName || rf.original_name);
|
|
1068
1074
|
const originalName = rf.originalName || rf.original_name || "file";
|
|
1069
1075
|
const fileSize = Number(rf.fileSize || rf.file_size || 0);
|
|
1070
|
-
const
|
|
1076
|
+
const rawUrl = rf.cloudUrl || rf.cloud_url || rf.url || "";
|
|
1077
|
+
let cloudUrl = rawUrl;
|
|
1078
|
+
if (this.storageServerUrl) {
|
|
1079
|
+
if (rawUrl.startsWith("http")) {
|
|
1080
|
+
cloudUrl = this.storageServerUrl.startsWith("https://") ? rawUrl.replace(/^http:\/\//, "https://") : rawUrl;
|
|
1081
|
+
} else if (rawUrl.startsWith("/")) {
|
|
1082
|
+
cloudUrl = `${this.storageServerUrl}${rawUrl}`;
|
|
1083
|
+
} else {
|
|
1084
|
+
cloudUrl = `${this.storageServerUrl}/cdn/${fileId}`;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1071
1087
|
const cloudPublicId = rf.cloudPublicId || rf.cloud_public_id || fileId;
|
|
1072
1088
|
const storageId = rf.storageId || rf.storage_id || null;
|
|
1073
1089
|
if (this.query) {
|
|
1090
|
+
let validStorageId = null;
|
|
1091
|
+
if (storageId) {
|
|
1092
|
+
try {
|
|
1093
|
+
const sCheck = await this.query(`SELECT 1 FROM ${this.storagesTable} WHERE id = $1 LIMIT 1`, [storageId]);
|
|
1094
|
+
if (sCheck && sCheck.rows && sCheck.rows.length > 0) {
|
|
1095
|
+
validStorageId = storageId;
|
|
1096
|
+
}
|
|
1097
|
+
} catch {
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1074
1100
|
const hasInspectionCol = await this._hasInspectionColumn();
|
|
1075
1101
|
const insertSql = hasInspectionCol ? `INSERT INTO ${this.filesTable} (
|
|
1076
1102
|
id, entity_type, entity_id, inspection_id, storage_id,
|
|
@@ -1094,7 +1120,7 @@ class StorageManager {
|
|
|
1094
1120
|
cloud_public_id = EXCLUDED.cloud_public_id,
|
|
1095
1121
|
updated_at = NOW()
|
|
1096
1122
|
RETURNING *`;
|
|
1097
|
-
const params = hasInspectionCol ? [fileId, entityType, entityId, inspectionId,
|
|
1123
|
+
const params = hasInspectionCol ? [fileId, entityType, entityId, inspectionId, validStorageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId] : [fileId, entityType, entityId, validStorageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId];
|
|
1098
1124
|
const r = await this.query(insertSql, params);
|
|
1099
1125
|
this.fileMetadataCache.set(fileId, { data: r.rows[0], ts: Date.now() });
|
|
1100
1126
|
return this.formatFileRecord(r.rows[0]);
|
|
@@ -284,7 +284,7 @@ export class StorageManager {
|
|
|
284
284
|
fileType,
|
|
285
285
|
mimeType,
|
|
286
286
|
fileSize: Number(f.file_size || 0),
|
|
287
|
-
|
|
287
|
+
cloudUrl: f.cloud_url || f.cloudUrl || null,
|
|
288
288
|
viewUrl: `${base}/cdn/${fileId}/view`,
|
|
289
289
|
downloadUrl: `${base}/cdn/${fileId}/download`,
|
|
290
290
|
url: `${base}/cdn/${fileId}`,
|
|
@@ -1044,6 +1044,13 @@ export class StorageManager {
|
|
|
1044
1044
|
return res.status(404).send('File URL not found');
|
|
1045
1045
|
}
|
|
1046
1046
|
|
|
1047
|
+
if (targetUrl.startsWith('/')) {
|
|
1048
|
+
const serverBase = (this.storageServerUrl || process.env?.STORAGE_SERVER_URL || '').replace(/\/+$/, '');
|
|
1049
|
+
if (serverBase) {
|
|
1050
|
+
targetUrl = `${serverBase}${targetUrl}`;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1047
1054
|
if (!/^https?:\/\//i.test(targetUrl)) {
|
|
1048
1055
|
targetUrl = `https://${targetUrl}`;
|
|
1049
1056
|
}
|
|
@@ -1195,11 +1202,31 @@ export class StorageManager {
|
|
|
1195
1202
|
const fileType = rf.fileType || rf.file_type || getFileTypeCategory(mimeType, rf.originalName || rf.original_name);
|
|
1196
1203
|
const originalName = rf.originalName || rf.original_name || 'file';
|
|
1197
1204
|
const fileSize = Number(rf.fileSize || rf.file_size || 0);
|
|
1198
|
-
const
|
|
1205
|
+
const rawUrl = rf.cloudUrl || rf.cloud_url || rf.url || '';
|
|
1206
|
+
let cloudUrl = rawUrl;
|
|
1207
|
+
if (this.storageServerUrl) {
|
|
1208
|
+
if (rawUrl.startsWith('http')) {
|
|
1209
|
+
cloudUrl = this.storageServerUrl.startsWith('https://') ? rawUrl.replace(/^http:\/\//, 'https://') : rawUrl;
|
|
1210
|
+
} else if (rawUrl.startsWith('/')) {
|
|
1211
|
+
cloudUrl = `${this.storageServerUrl}${rawUrl}`;
|
|
1212
|
+
} else {
|
|
1213
|
+
cloudUrl = `${this.storageServerUrl}/cdn/${fileId}`;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1199
1216
|
const cloudPublicId = rf.cloudPublicId || rf.cloud_public_id || fileId;
|
|
1200
1217
|
const storageId = rf.storageId || rf.storage_id || null;
|
|
1201
1218
|
|
|
1202
1219
|
if (this.query) {
|
|
1220
|
+
let validStorageId = null;
|
|
1221
|
+
if (storageId) {
|
|
1222
|
+
try {
|
|
1223
|
+
const sCheck = await this.query(`SELECT 1 FROM ${this.storagesTable} WHERE id = $1 LIMIT 1`, [storageId]);
|
|
1224
|
+
if (sCheck && sCheck.rows && sCheck.rows.length > 0) {
|
|
1225
|
+
validStorageId = storageId;
|
|
1226
|
+
}
|
|
1227
|
+
} catch {}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1203
1230
|
const hasInspectionCol = await this._hasInspectionColumn();
|
|
1204
1231
|
const insertSql = hasInspectionCol
|
|
1205
1232
|
? `INSERT INTO ${this.filesTable} (
|
|
@@ -1227,8 +1254,8 @@ export class StorageManager {
|
|
|
1227
1254
|
RETURNING *`;
|
|
1228
1255
|
|
|
1229
1256
|
const params = hasInspectionCol
|
|
1230
|
-
? [fileId, entityType, entityId, inspectionId,
|
|
1231
|
-
: [fileId, entityType, entityId,
|
|
1257
|
+
? [fileId, entityType, entityId, inspectionId, validStorageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId]
|
|
1258
|
+
: [fileId, entityType, entityId, validStorageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId];
|
|
1232
1259
|
|
|
1233
1260
|
const r = await this.query(insertSql, params);
|
|
1234
1261
|
this.fileMetadataCache.set(fileId, { data: r.rows[0], ts: Date.now() });
|