diff --git a/package-lock.json b/package-lock.json index fac582b..3ebed1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1", "@tnsu/ui-kit": "^0.11.0", + "ncalayer-js-client": "^1.6.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" @@ -15087,6 +15088,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ncalayer-js-client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ncalayer-js-client/-/ncalayer-js-client-1.6.0.tgz", + "integrity": "sha512-poSneFadh+I8630ZmycLhKPZTn7Gqju0/TiMmWXnqEsG0mQKrEH7ZbeLwYrynryotReHnLYTA5VLZr8TnE3X5w==", + "license": "MIT", + "dependencies": { + "ws": "^8.4.2" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -19960,7 +19970,6 @@ "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 847bdd5..a687f91 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1", "@tnsu/ui-kit": "^0.11.0", + "ncalayer-js-client": "^1.6.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..f1d5bca --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,39 @@ +// Ambient-типы для NCALayer JS client (у пакета нет собственных .d.ts). +// Используется только CMS-подписание через десктопный NCALayer (см. SigexSignService). +declare module 'ncalayer-js-client' { + export class NCALayerError extends Error { + canceledByUser?: boolean; + } + + export interface CMSParams { + decode?: boolean; + encapsulate?: boolean; + digested?: boolean; + tsaProfile?: object; + } + + export interface SignerParams { + extKeyUsageOids: string[]; + chain?: unknown[]; + } + + export class NCALayerClient { + constructor(url?: string, allowKmdHttpApi?: boolean); + + static basicsStorageAll: null; + + static basicsCMSParamsDetachedNoTSP: CMSParams; + + static basicsSignerSignAny: SignerParams; + + connect(): Promise; + + basicsSignCMS( + allowedStorages: string[] | null, + data: string | ArrayBuffer | Blob | File, + signingParams: CMSParams, + signerParams: SignerParams, + locale?: string, + ): Promise; + } +} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.module.scss b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.module.scss index f545861..707f733 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.module.scss +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.module.scss @@ -265,3 +265,17 @@ .dialogError { margin-top: 12px; } + +.incoreProgress { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + font-size: 14px; +} + +.incoreHint { + margin: 8px 0 0; + font-size: 13px; + color: $muted; +} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.tsx index 0c292b3..e1e41d9 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/Step4Approval.tsx @@ -3,7 +3,7 @@ import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon, import { ApprovalMemberStatus, FormStep } from "../types/domain"; import { CtService } from "../../services/CtService"; import { CTFormData } from "../hooks/useCtForm"; -import { useStep4Approval } from "./useStep4Approval"; +import { useStep4Approval, IncoreStep } from "./useStep4Approval"; import styles from "./Step4Approval.module.scss"; interface Props { @@ -23,6 +23,15 @@ const STATUS_LABELS: Record = { skipped: "Пропущено", }; +const INCORE_STEP_LABELS: Record = { + report: "Формирование отчёта СТ…", + connecting: "Подключение к NCALayer…", + signing: "Ожидание подписи в NCALayer…", + registering: "Регистрация документа в SIGEX…", + finalizing: "Фиксация документа…", + building: "Формирование карточки с ЭЦП…", +}; + function getStatusClass(status: ApprovalMemberStatus): string { const map: Record = { pending: styles.statusPending, @@ -379,6 +388,42 @@ export const Step4Approval: React.FC = (props) => { )} )} + + {/* INCORE: прогресс подписания и сборки карточки */} + {a.incoreStep && ( + undefined} + > +
+ + {INCORE_STEP_LABELS[a.incoreStep]} +
+ {a.incoreStep === "signing" && ( +

+ Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне + NCALayer. +

+ )} +
+ )} + + {/* INCORE: ошибка подписания */} + {a.incoreError && ( + + Закрыть + + } + > + {a.incoreError} + + )} ); }; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts index e3512f9..b1e81f3 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts @@ -1,8 +1,15 @@ import { useState, useCallback, useRef } from 'react'; import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain'; import { CtService } from '../../services/CtService'; +import { SigexSignService, SigexSignStep } from '../../services/SigexSignService'; import { CTFormData } from '../hooks/useCtForm'; +// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX. +export type IncoreStep = 'report' | SigexSignStep; + +// Подпись через SIGEX без состояния — один экземпляр на модуль. +const sigexSign = new SigexSignService(); + export type ApprovalAction = 'approve' | 'reject'; export interface ActionDialogState { @@ -44,6 +51,8 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) { const [submitError, setSubmitError] = useState(null); const [notifyWarning, setNotifyWarning] = useState(null); const [isSendingToIncore, setIsSendingToIncore] = useState(false); + const [incoreStep, setIncoreStep] = useState(null); + const [incoreError, setIncoreError] = useState(null); // ─── Add member ─── const [showAddRow, setShowAddRow] = useState(false); @@ -324,9 +333,22 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) { const handleSendToIncore = async (): Promise => { if (!ct) return; setIsSendingToIncore(true); - setSubmitError(null); + setIncoreError(null); try { - await service.generateCtReport(ct.id); + // 1. Отчёт СТ в PDF — карточка SIGEX визуализирует именно PDF. + setIncoreStep('report'); + const reportBlob = await service.getCtReportBlob(ct.id, 'pdf'); + const title = `CT_${ct.id}.pdf`; + + // 2. Подпись NCALayer → регистрация → фиксация → карточка (DDC). + const cardBlob = await sigexSign.signAndBuildCard( + reportBlob, + title, + setIncoreStep, + ); + + // 3. Пакет для INCORE: карточка с ЭЦП + файлы КП. + service.downloadBlob(cardBlob, `Карточка_CT_${ct.id}.pdf`); const kpFiles = formData.uploadedKpFiles ?? []; for (const f of kpFiles) { @@ -334,8 +356,13 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) { await new Promise((resolve) => setTimeout(resolve, 300)); } } catch (err) { - setSubmitError((err as Error).message ?? 'Ошибка скачивания файлов'); + // Пользователь закрыл окно NCALayer, не подписав — это не ошибка. + if ((err as { canceledByUser?: boolean }).canceledByUser) return; + setIncoreError( + (err as Error).message ?? 'Ошибка подписания и выгрузки пакета', + ); } finally { + setIncoreStep(null); setIsSendingToIncore(false); } }; @@ -384,6 +411,9 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) { dismissNotifyWarning: () => setNotifyWarning(null), isSubmitting, isSendingToIncore, + incoreStep, + incoreError, + dismissIncoreError: () => setIncoreError(null), isOnApproval, showStatuses, isInitiator, diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index fa2e578..13f6f67 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -1505,7 +1505,9 @@ export class CtService { throw new Error(`Ошибка сохранения комментария ИИ: ${res.status}`); } - async generateCtReport(ctId: string, format: string = "xlsx"): Promise { + // Сформировать отчёт СТ и вернуть его как blob (без скачивания на диск). + // Нужен для подписания через NCALayer и сборки карточки SIGEX. + async getCtReportBlob(ctId: string, format: string = "xlsx"): Promise { const token = await this._getApiToken(); const res = await fetch( `${EXTERNAL_API}/Report/CT?ctid=${parseInt(ctId, 10)}&format=${format}`, @@ -1514,11 +1516,20 @@ export class CtService { }, ); if (!res.ok) throw new Error(`Ошибка формирования отчёта: ${res.status}`); - const blob = await res.blob(); + return res.blob(); + } + + async generateCtReport(ctId: string, format: string = "xlsx"): Promise { + const blob = await this.getCtReportBlob(ctId, format); + this.downloadBlob(blob, `CT_${ctId}.${format}`); + } + + // Скачать blob как файл под указанным именем. + downloadBlob(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `CT_${ctId}.${format}`; + a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); diff --git a/src/webparts/ctFormsPortal/forms/ct/services/SigexSignService.ts b/src/webparts/ctFormsPortal/forms/ct/services/SigexSignService.ts new file mode 100644 index 0000000..4e47261 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/services/SigexSignService.ts @@ -0,0 +1,147 @@ +import { NCALayerClient } from 'ncalayer-js-client'; + +// Этапы подписания — для отображения прогресса в UI. +export type SigexSignStep = + | 'connecting' // подключение к NCALayer + | 'signing' // ожидание выбора сертификата и PIN в окне NCALayer + | 'registering' // регистрация документа в SIGEX + | 'finalizing' // фиксация хэша документа + | 'building'; // сборка карточки электронного документа + +// Официальный сервис проверки/регистрации ЭЦП (карточка = DDC). +// API: https://sigex.kz/en/support/developers/api-documents/ +const SIGEX_BASE_URL = 'https://sigex.kz'; + +interface RegisterResponse { + documentId?: string; + message?: string; +} + +interface BuildDdcResponse { + ddc?: string; // карточка электронного документа в виде base64-строки + message?: string; +} + +// Подписание PDF через десктопный NCALayer и формирование карточки +// электронного документа (DDC) в SIGEX. Мобильный/QR-сценарий не используется — +// подпись только ключом ЭЦП на ПК подписанта. +export class SigexSignService { + // Подписать PDF (base64) — CMS detached без метки времени, ключ SIGN. + async signWithNcaLayer(pdfBase64: string): Promise { + const client = new NCALayerClient(); + await client.connect(); + return client.basicsSignCMS( + NCALayerClient.basicsStorageAll, + pdfBase64, + NCALayerClient.basicsCMSParamsDetachedNoTSP, + NCALayerClient.basicsSignerSignAny, + ); + } + + // Зарегистрировать документ в SIGEX вместе с первой подписью → documentId. + async registerDocument(title: string, cmsBase64: string): Promise { + const res = await fetch(`${SIGEX_BASE_URL}/api`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + signType: 'cms', + signature: cmsBase64, + settings: {}, + }), + }); + const data = (await res.json()) as RegisterResponse; + if (!res.ok || !data.documentId) { + throw new Error(`SIGEX: ${data.message ?? `HTTP ${res.status}`}`); + } + return data.documentId; + } + + // Зафиксировать хэш документа — обязательный шаг перед сборкой карточки. + async finalizeDocument(documentId: string, pdfBlob: Blob): Promise { + const res = await fetch(`${SIGEX_BASE_URL}/api/${documentId}/data`, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: pdfBlob, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`SIGEX (фиксация документа): ${res.status} ${text}`); + } + } + + // Собрать карточку электронного документа (DDC) — PDF с оригиналом, подписью + // и визуализацией. qrWithIDLink=true — QR со ссылкой на страницу проверки. + async buildDdc(documentId: string, pdfBlob: Blob, fileName: string): Promise { + const query = new URLSearchParams({ + fileName, + qrWithIDLink: 'true', + language: 'ru', + }); + const res = await fetch( + `${SIGEX_BASE_URL}/api/${documentId}/buildDDC?${query.toString()}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: pdfBlob, + }, + ); + const data = (await res.json()) as BuildDdcResponse; + if (!res.ok || !data.ddc) { + throw new Error(`SIGEX (сборка карточки): ${data.message ?? `HTTP ${res.status}`}`); + } + return this._base64ToPdfBlob(data.ddc); + } + + // Полный сценарий: подпись PDF → регистрация → фиксация → карточка. + // onProgress уведомляет UI о текущем этапе. + async signAndBuildCard( + pdfBlob: Blob, + title: string, + onProgress: (step: SigexSignStep) => void, + ): Promise { + const pdfBase64 = await this._blobToBase64(pdfBlob); + + onProgress('connecting'); + const client = new NCALayerClient(); + await client.connect(); + + onProgress('signing'); + const cms = await client.basicsSignCMS( + NCALayerClient.basicsStorageAll, + pdfBase64, + NCALayerClient.basicsCMSParamsDetachedNoTSP, + NCALayerClient.basicsSignerSignAny, + ); + + onProgress('registering'); + const documentId = await this.registerDocument(title, cms); + + onProgress('finalizing'); + await this.finalizeDocument(documentId, pdfBlob); + + onProgress('building'); + return this.buildDdc(documentId, pdfBlob, title); + } + + private async _blobToBase64(blob: Blob): Promise { + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + private _base64ToPdfBlob(base64: string): Blob { + const normalized = base64.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, ''); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: 'application/pdf' }); + } +}