diff --git a/README.md b/README.md index 8619db8..4c28575 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,14 @@ ## Архитектура -- **Backend**: Fastify + TypeScript -- **Frontend**: React + Vite + TypeScript +- **Backend**: Fastify + TypeScript (прокси-сервер к True API) +- **Frontend**: React + Vite + TypeScript (SPA с интеграцией КриптоПро Browser Plugin) - **Docker**: docker-compose (2 сервиса: backend, frontend) ## Быстрый старт ```bash -# Клонировать и перейти в директорию cd chestno-api - -# Настроить окружение -cp backend/.env.example backend/.env -# Отредактировать backend/.env — указать thumbprint сертификата - -# Запустить docker-compose up --build ``` @@ -30,14 +23,10 @@ docker-compose up --build ```bash # Backend -cd backend -npm install -npm run dev +cd backend && npm install && npm run dev # Frontend (в отдельном терминале) -cd frontend -npm install -npm run dev +cd frontend && npm install && npm run dev ``` ## Структура проекта @@ -47,39 +36,34 @@ backend/ src/ config.ts # Конфигурация index.ts # Точка входа Fastify - routes/ - auth.ts # GET /auth/key, POST /auth/signin, GET /auth/status - cises.ts # POST /api/check-codes/public, /api/check-codes/auth - upd.ts # POST /api/upload-upd - services/ - auth-service.ts # True API аутентификация + подпись через КриптоПро - cises-service.ts # Проверка КМ (публичная / авторизованная) - token-cache.ts # Кеширование токена (node-cache, TTL 10ч) - upd-parser.ts # Парсинг XML УПД (формат Приказа №970) - types/ - index.ts # TypeScript типы + routes/auth.ts # GET /auth/key, POST /auth/simpleSignIn, GET /auth/status, POST /auth/logout + routes/cises.ts # POST /api/check-codes/public, POST /api/check-codes/auth + routes/upd.ts # POST /api/upload-upd + services/auth-service.ts # Прокси к True API auth + services/cises-service.ts # Проверка КМ (публичная / авторизованная) + services/token-cache.ts # Кеширование токена (node-cache, TTL 10ч) + services/upd-parser.ts # Парсинг XML УПД (формат Приказа №970) + types/index.ts frontend/ src/ - App.tsx # Корневой компонент с навигацией - pages/ - AuthPage.tsx # Авторизация - CheckCodesPage.tsx# Проверка кодов - UploadUpdPage.tsx # Загрузка УПД - components/ - ResultsTable.tsx # Таблица результатов с цветовой индикацией - services/ - api.ts # HTTP-клиент к backend - types/ - index.ts # TypeScript типы + App.tsx + pages/AuthPage.tsx # Авторизация через КриптоПро Browser Plugin + pages/CheckCodesPage.tsx # Проверка кодов + pages/UploadUpdPage.tsx # Загрузка УПД + components/ResultsTable.tsx + services/api.ts # HTTP-клиент + services/cadesplugin.ts # Интеграция с КриптоПро Browser Plugin + types/index.ts ``` ## API Endpoints ### Аутентификация - `GET /auth/key` — получение UUID + data для подписи -- `POST /auth/signin` — отправка подписи, получение токена +- `POST /auth/simpleSignIn` — отправка подписи, получение токена - `GET /auth/status` — статус авторизации +- `POST /auth/logout` — сброс токена ### Проверка кодов - `POST /api/check-codes/public` — публичная проверка (без авторизации) @@ -96,14 +80,14 @@ frontend/ | `HOST` | Хост | `0.0.0.0` | | `TRUE_API_URL` | URL True API | `https://markirovka.sandbox.crptech.ru/api/v3/true-api` | | `PUBLIC_CHECK_URL` | URL публичного API | `https://mobile.api.crpt.ru/mobile/check` | -| `CERT_THUMBPRINT` | Thumbprint сертификата КриптоПро | — | -## Подпись данных через КриптоПро +## Процесс авторизации -Подпись выполняется через CLI-утилиту `cryptcp` (входит в состав КриптоПро CSP). Установите КриптоПро CSP и укажите thumbprint сертификата в `CERT_THUMBPRINT`. +1. Пользователь нажимает «Подписать и войти» в браузере +2. Frontend получает UUID + data от `GET /auth/key` +3. КриптоПро Browser Plugin подписывает data (CAdES-BES, присоединённая подпись, base64) +4. Подпись отправляется на `POST /auth/simpleSignIn` +5. Backend проксирует запрос в True API, кеширует токен +6. Токен используется для авторизованных запросов (10 часов) -### Получение thumbprint сертификата - -```bash -cryptcp -list -``` +> Подпись выполняется **на стороне клиента** — закрытый ключ УКЭП не покидает устройство пользователя. diff --git a/backend/.env.example b/backend/.env.example index 8f1830a..e9ac3c6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,4 +2,4 @@ PORT=3001 HOST=0.0.0.0 TRUE_API_URL=https://markirovka.sandbox.crptech.ru/api/v3/true-api PUBLIC_CHECK_URL=https://mobile.api.crpt.ru/mobile/check -CERT_THUMBPRINT=your-certificate-thumbprint-here +CORS_ORIGIN=* diff --git a/backend/src/config.ts b/backend/src/config.ts index d46c521..a52c73a 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -2,6 +2,8 @@ export const config = { port: parseInt(process.env.PORT || '3001', 10), host: process.env.HOST || '0.0.0.0', + corsOrigin: process.env.CORS_ORIGIN || '*', + trueApi: { baseUrl: process.env.TRUE_API_URL || 'https://markirovka.sandbox.crptech.ru/api/v3/true-api', publicCheckUrl: process.env.PUBLIC_CHECK_URL || 'https://mobile.api.crpt.ru/mobile/check', @@ -10,5 +12,7 @@ export const config = { codesCheckPath: '/codes/check', batchSize: 1000, tokenTTL: 10 * 60 * 60 * 1000, + maxRetries: 3, + publicCheckConcurrency: 10, }, } diff --git a/backend/src/index.ts b/backend/src/index.ts index 584ee0d..4e382b0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,8 +8,14 @@ import { updRoutes } from './routes/upd.js' const app = Fastify({ logger: true }) -await app.register(cors, { origin: true }) -await app.register(multipart) +await app.register(cors, { origin: config.corsOrigin }) +await app.register(multipart, { + limits: { + fileSize: 10 * 1024 * 1024, + files: 1, + }, + throwFileSizeLimit: true, +}) await app.register(authRoutes) await app.register(cisesRoutes) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index dfd8ce0..7964674 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,6 +1,17 @@ import type { FastifyInstance } from 'fastify' -import { getAuthKey, signIn } from '../services/auth-service.js' -import { getTokenTTL, hasToken } from '../services/token-cache.js' +import { getAuthKey, signIn, clearToken, isAuthenticated } from '../services/auth-service.js' +import { getTokenTTL } from '../services/token-cache.js' + +const signInSchema = { + body: { + type: 'object', + required: ['uuid', 'data'], + properties: { + uuid: { type: 'string' }, + data: { type: 'string' }, + }, + }, +} export async function authRoutes(app: FastifyInstance): Promise { app.get('/auth/key', async () => { @@ -8,18 +19,23 @@ export async function authRoutes(app: FastifyInstance): Promise { return { uuid: authKey.uuid, data: authKey.data } }) - app.post<{ Body: { uuid: string; data: string } }>('/auth/signin', async (request) => { + app.post<{ Body: { uuid: string; data: string } }>('/auth/simpleSignIn', { schema: signInSchema }, async (request) => { const { uuid, data } = request.body const result = await signIn({ uuid, data }) return result }) app.get('/auth/status', async () => { - const authenticated = hasToken() + const authenticated = isAuthenticated() const ttl = getTokenTTL() return { authenticated, tokenExpiresAt: ttl ? new Date(ttl).toISOString() : null, } }) + + app.post('/auth/logout', async () => { + clearToken() + return { success: true } + }) } diff --git a/backend/src/routes/cises.ts b/backend/src/routes/cises.ts index a0af350..b3e0e86 100644 --- a/backend/src/routes/cises.ts +++ b/backend/src/routes/cises.ts @@ -1,13 +1,35 @@ import type { FastifyInstance } from 'fastify' -import { checkCodesPublic, checkCodesAuth } from '../services/cises-service.js' +import { checkCodesPublic, checkCodesAuth, AppError } from '../services/cises-service.js' import type { CheckCodesRequest } from '../types/index.js' +const checkCodesSchema = { + body: { + type: 'object', + required: ['codes'], + properties: { + codes: { + type: 'array', + items: { type: 'string', minLength: 1, maxLength: 256 }, + minItems: 1, + maxItems: 10000, + }, + }, + }, +} + export async function cisesRoutes(app: FastifyInstance): Promise { - app.post<{ Body: CheckCodesRequest }>('/api/check-codes/public', async (request) => { + app.post<{ Body: CheckCodesRequest }>('/api/check-codes/public', { schema: checkCodesSchema }, async (request) => { return checkCodesPublic(request.body) }) - app.post<{ Body: CheckCodesRequest }>('/api/check-codes/auth', async (request) => { - return checkCodesAuth(request.body) + app.post<{ Body: CheckCodesRequest }>('/api/check-codes/auth', { schema: checkCodesSchema }, async (request, reply) => { + try { + return await checkCodesAuth(request.body) + } catch (err) { + if (err instanceof AppError) { + return reply.status(err.statusCode).send({ error: err.message }) + } + throw err + } }) } diff --git a/backend/src/routes/upd.ts b/backend/src/routes/upd.ts index 68e99d9..2b2e4f0 100644 --- a/backend/src/routes/upd.ts +++ b/backend/src/routes/upd.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify' import { parseUpdXml } from '../services/upd-parser.js' -import { checkCodesAuth } from '../services/cises-service.js' +import { checkCodesAuth, AppError } from '../services/cises-service.js' export async function updRoutes(app: FastifyInstance): Promise { app.post('/api/upload-upd', async (request, reply) => { @@ -13,24 +13,36 @@ export async function updRoutes(app: FastifyInstance): Promise { const xmlContent = buffer.toString('utf-8') const fileName = data.filename - const parsed = await parseUpdXml(xmlContent, fileName) + let parsed + try { + parsed = await parseUpdXml(xmlContent, fileName) + } catch { + return reply.status(400).send({ error: 'Invalid XML file' }) + } if (parsed.codes.length === 0) { return reply.status(400).send({ error: 'No marking codes found in UPD' }) } - const checkResults = await checkCodesAuth({ codes: parsed.codes }) + try { + const checkResults = await checkCodesAuth({ codes: parsed.codes }) - return { - document: { - number: parsed.documentNumber, - date: parsed.documentDate, - seller: parsed.sellerName, - buyer: parsed.buyerName, - fileName: parsed.fileName, - }, - codesFound: parsed.codes.length, - results: checkResults, + return { + document: { + number: parsed.documentNumber, + date: parsed.documentDate, + seller: parsed.sellerName, + buyer: parsed.buyerName, + fileName: parsed.fileName, + }, + codesFound: parsed.codes.length, + results: checkResults, + } + } catch (err) { + if (err instanceof AppError) { + return reply.status(err.statusCode).send({ error: err.message }) + } + throw err } }) } diff --git a/backend/src/services/auth-service.ts b/backend/src/services/auth-service.ts index 883191e..7297cc4 100644 --- a/backend/src/services/auth-service.ts +++ b/backend/src/services/auth-service.ts @@ -1,6 +1,6 @@ import got from 'got' import { config } from '../config.js' -import { getToken, setToken } from './token-cache.js' +import { getToken, setToken, invalidateToken } from './token-cache.js' import type { AuthKeyResponse, AuthSignInRequest, AuthSignInResponse } from '../types/index.js' const authClient = got.extend({ @@ -21,29 +21,14 @@ export async function signIn(body: AuthSignInRequest): Promise { - const cached = getToken() - if (cached) return cached - - const { uuid, data } = await getAuthKey() - const signature = await signData(data) - const { token } = await signIn({ uuid, data: signature }) - return token +export function getCachedToken(): string | undefined { + return getToken() } -async function signData(data: string): Promise { - try { - const { execSync } = await import('child_process') +export function clearToken(): void { + invalidateToken() +} - const thumbprint = process.env.CERT_THUMBPRINT - if (!thumbprint) throw new Error('CERT_THUMBPRINT not set') - - const result = execSync( - `cryptcp -sign -detached -base64 -thumbprint "${thumbprint}" -in <(echo -n "${data}")`, - { encoding: 'utf-8' } - ) - return result.trim() - } catch (err) { - throw new Error(`Failed to sign data with CryptoPro: ${err}`) - } +export function isAuthenticated(): boolean { + return !!getToken() } diff --git a/backend/src/services/cises-service.ts b/backend/src/services/cises-service.ts index e5745aa..076b503 100644 --- a/backend/src/services/cises-service.ts +++ b/backend/src/services/cises-service.ts @@ -1,6 +1,6 @@ import got from 'got' import { config } from '../config.js' -import { getValidToken } from './auth-service.js' +import { getCachedToken, clearToken } from './auth-service.js' import type { CisInfoResponse, PublicCheckResponse, @@ -16,75 +16,145 @@ const apiClient = got.extend({ export async function checkCodesPublic(body: CheckCodesRequest): Promise { const results: SingleCodeResult[] = [] + const queue = [...body.codes] - for (const code of body.codes) { - try { - const { body: response } = await got.get( - `${config.trueApi.publicCheckUrl}?code=${encodeURIComponent(code)}`, - { responseType: 'json' } - ) - results.push({ - code: response.code, - found: response.found, - valid: response.valid, - status: response.status, - }) - } catch { - results.push({ - code, - found: false, - valid: false, - status: 'ERROR', - error: 'Public check API error', - }) + async function worker() { + while (queue.length > 0) { + const code = queue.shift()! + try { + const { body: response } = await got.get( + `${config.trueApi.publicCheckUrl}?code=${encodeURIComponent(code)}`, + { responseType: 'json', timeout: { request: 10000 } } + ) + results.push({ + code: response.code, + found: response.found, + valid: response.valid, + status: response.status, + }) + } catch { + results.push({ + code, + found: false, + valid: false, + status: 'ERROR', + error: 'Public check API error', + }) + } + } + } + + const workers = Array.from({ length: config.trueApi.publicCheckConcurrency }, () => worker()) + await Promise.all(workers) + + return buildResponse(results) +} + +function getToken(): string { + const token = getCachedToken() + if (!token) throw new AppError('Not authenticated', 401) + return token +} + +export async function checkCodesAuth(body: CheckCodesRequest): Promise { + const token = getToken() + const results: SingleCodeResult[] = [] + + try { + await processBatch(body.codes, token, results, 0) + } catch (err) { + if (err instanceof AppError && err.statusCode === 401) { + clearToken() + throw err + } + for (const code of body.codes) { + if (!results.find(r => r.code === code)) { + results.push({ code, found: false, valid: false, status: 'ERROR', error: 'Auth API error' }) + } } } return buildResponse(results) } -export async function checkCodesAuth(body: CheckCodesRequest): Promise { - const token = await getValidToken() - const results: SingleCodeResult[] = [] +async function processBatch( + codes: string[], token: string, results: SingleCodeResult[], batchIndex: number +): Promise { + if (batchIndex >= codes.length) return - for (let i = 0; i < body.codes.length; i += config.trueApi.batchSize) { - const batch = body.codes.slice(i, i + config.trueApi.batchSize) + const start = batchIndex + const end = Math.min(start + config.trueApi.batchSize, codes.length) + const batch = codes.slice(start, end) - try { - const { body: response } = await apiClient.post( - config.trueApi.cisesInfoPath, - { - json: { cisList: batch }, - headers: { Authorization: `Bearer ${token}` }, - } - ) + await retryOnError(batch, token, results) - for (const item of response.cisInfo) { - results.push({ - code: item.requestedCis, - found: !!item.cis, - valid: item.status === 'INTRODUCED' || item.status === 'APPLIED' || item.status === 'EMITTED', - status: item.status || 'UNKNOWN', - gtin: item.gtin, - productName: item.productName, - producerName: item.producerName, - ownerName: item.ownerName, - }) - } - } catch { - for (const code of batch) { - results.push({ - code, - found: false, - valid: false, - status: 'ERROR', - error: 'Auth API error', - }) + await processBatch(codes, token, results, end) +} + +async function retryOnError( + batch: string[], token: string, results: SingleCodeResult[], attempt = 0 +): Promise { + try { + const { body: response } = await apiClient.post( + config.trueApi.cisesInfoPath, + { + json: { cisList: batch }, + headers: { Authorization: `Bearer ${token}` }, + timeout: { request: 30000 }, } + ) + + for (const item of response.cisInfo) { + results.push({ + code: item.requestedCis, + found: !!item.cis, + valid: item.status === 'INTRODUCED' || item.status === 'APPLIED' || item.status === 'EMITTED', + status: item.status || 'UNKNOWN', + gtin: item.gtin, + productName: item.productName, + producerName: item.producerName, + ownerName: item.ownerName, + }) + } + } catch (err) { + if (is401(err)) { + throw new AppError('Token expired', 401) } - } - return buildResponse(results) + const isRetryable = isRetryableError(err) + if (isRetryable && attempt < config.trueApi.maxRetries) { + const delay = Math.pow(2, attempt) * 500 + await sleep(delay) + return retryOnError(batch, token, results, attempt + 1) + } + + throw err + } +} + +function is401(err: unknown): boolean { + if (!err || typeof err !== 'object') return false + return 'response' in err && + (err as { response?: { statusCode?: number } }).response?.statusCode === 401 +} + +function isRetryableError(err: unknown): boolean { + if (!err || typeof err !== 'object') return true + const statusCode = (err as { response?: { statusCode?: number } }).response?.statusCode + if (!statusCode) return true + return statusCode === 429 || statusCode >= 500 +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export class AppError extends Error { + statusCode: number + constructor(message: string, statusCode: number) { + super(message) + this.statusCode = statusCode + } } function buildResponse(results: SingleCodeResult[]): CheckCodesResponse { diff --git a/backend/src/services/token-cache.ts b/backend/src/services/token-cache.ts index 670d79b..5f03244 100644 --- a/backend/src/services/token-cache.ts +++ b/backend/src/services/token-cache.ts @@ -23,3 +23,7 @@ export function hasToken(): boolean { export function getTokenTTL(): number | undefined { return cache.getTtl(TOKEN_KEY) } + +export function invalidateToken(): void { + cache.del(TOKEN_KEY) +} diff --git a/backend/src/services/upd-parser.ts b/backend/src/services/upd-parser.ts index dab4527..7662c07 100644 --- a/backend/src/services/upd-parser.ts +++ b/backend/src/services/upd-parser.ts @@ -11,7 +11,6 @@ export interface ParsedUpd { export async function parseUpdXml(xmlContent: string, fileName: string): Promise { const parsed = await parseStringPromise(xmlContent, { - explicitArray: false, ignoreAttrs: false, mergeAttrs: true, }) @@ -21,42 +20,44 @@ export async function parseUpdXml(xmlContent: string, fileName: string): Promise return { codes, fileName, - documentNumber: extractField(parsed, 'Документ', 'НомерДок'), - documentDate: extractField(parsed, 'Документ', 'ДатаДок'), - sellerName: extractField(parsed, 'Документ', 'ТаблСчФакт', 'СведПрод', 'НаимОрг'), - buyerName: extractField(parsed, 'Документ', 'ТаблСчФакт', 'СведПокуп', 'НаимОрг'), + documentNumber: extractField(parsed, ['Документ', 'НомерДок']), + documentDate: extractField(parsed, ['Документ', 'ДатаДок']), + sellerName: extractField(parsed, ['Документ', 'ТаблСчФакт', 'СведПрод', 'НаимОрг']), + buyerName: extractField(parsed, ['Документ', 'ТаблСчФакт', 'СведПокуп', 'НаимОрг']), } } function extractCodes(obj: Record): string[] { const codes: string[] = [] + const documents = asArray>(obj['Документ']) - try { - const document = obj['Документ'] as Record - const table = document['ТаблСчФакт'] as Record - const items = table['СведТов'] as Record[] + for (const document of documents) { + try { + const table = document['ТаблСчФакт'] as Record | undefined + if (!table) continue + const items = asArray>(table['СведТов']) - if (Array.isArray(items)) { for (const item of items) { - const extInfo = item['ДопСведТов'] as Record - if (extInfo) { - const kmCodes = extInfo['НомСредИдентТов'] - if (Array.isArray(kmCodes)) { - codes.push(...kmCodes.filter(Boolean)) - } else if (typeof kmCodes === 'string') { - codes.push(kmCodes) - } + const extInfo = asArray>(item['ДопСведТов']) + for (const info of extInfo) { + const kmCodes = asArray(info['НомСредИдентТов']) + codes.push(...kmCodes.filter(Boolean)) } } + } catch { + console.warn('upd-parser: failed to extract codes from a document') } - } catch { - // XML structure may vary; return what we found } return codes } -function extractField(obj: Record, ...keys: string[]): string | undefined { +function asArray(value: unknown): T[] { + if (value == null) return [] + return Array.isArray(value) ? value as T[] : [value as T] +} + +function extractField(obj: Record, keys: string[]): string | undefined { let current: unknown = obj for (const key of keys) { if (current && typeof current === 'object') { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2eb6069..f2ae5cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "chestny-znak-api-frontend", "version": "1.0.0", "dependencies": { + "crypto-pro": "^2.3.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -1725,6 +1726,12 @@ "node": ">= 8" } }, + "node_modules/crypto-pro": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/crypto-pro/-/crypto-pro-2.3.0.tgz", + "integrity": "sha512-cppYuMRRijEaZ0UKAOW5L1CQIvzQA3DTRp4pARINl6K8Pp1JMhlch5TyWwv6Z4Y3x677A9xxcW62okHD91Veqg==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0df5808..90cf548 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "crypto-pro": "^2.3.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -18,8 +19,8 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.19.0", "typescript": "^5.7.3", - "vite": "^6.1.0", - "eslint": "^9.19.0" + "vite": "^6.1.0" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 855a446..c8394fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { AuthPage } from './pages/AuthPage' import { CheckCodesPage } from './pages/CheckCodesPage' import { UploadUpdPage } from './pages/UploadUpdPage' +import { setOnUnauthorized, logout as apiLogout } from './services/api' type Page = 'auth' | 'check' | 'upload' @@ -9,6 +10,24 @@ export default function App() { const [page, setPage] = useState('auth') const [authenticated, setAuthenticated] = useState(false) + useEffect(() => { + setOnUnauthorized(() => { + setAuthenticated(false) + apiLogout().catch(() => {}) + }) + }, []) + + function handleAuth() { + setAuthenticated(true) + setPage('check') + } + + function handleLogout() { + setAuthenticated(false) + apiLogout().catch(() => {}) + setPage('auth') + } + return (
-
+
{authenticated ? '✅ Авторизован' : '❌ Не авторизован'} + {authenticated && ( + + )}
- {page === 'auth' && setAuthenticated(true)} />} + {page === 'auth' && } {page === 'check' && } {page === 'upload' && }
diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index 7fda634..674dc89 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react' import { getAuthStatus, getAuthKey, signIn } from '../services/api' +import { isCadesPluginAvailable, listCertificates, signWithCadesPlugin } from '../services/cadesplugin' +import type { CertInfo } from '../services/cadesplugin' interface Props { onAuth: () => void @@ -7,27 +9,25 @@ interface Props { export function AuthPage({ onAuth }: Props) { const [status, setStatus] = useState<{ authenticated: boolean; tokenExpiresAt: string | null } | null>(null) - const [uuid, setUuid] = useState('') - const [dataToSign, setDataToSign] = useState('') - const [signature, setSignature] = useState('') + const [hasPlugin, setHasPlugin] = useState(null) + const [certs, setCerts] = useState([]) + const [selectedThumbprint, setSelectedThumbprint] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') useEffect(() => { getAuthStatus().then(setStatus).catch(() => {}) + checkPlugin() }, []) - async function handleGetKey() { - setLoading(true) - setError('') - try { - const { uuid, data } = await getAuthKey() - setUuid(uuid) - setDataToSign(data) - } catch (err) { - setError(String(err)) - } finally { - setLoading(false) + async function checkPlugin() { + const available = await isCadesPluginAvailable() + setHasPlugin(available) + if (available) { + try { + const certList = await listCertificates() + setCerts(certList) + } catch {} } } @@ -35,12 +35,14 @@ export function AuthPage({ onAuth }: Props) { setLoading(true) setError('') try { + const { uuid, data } = await getAuthKey() + const signature = await signWithCadesPlugin(data, selectedThumbprint || undefined) await signIn(uuid, signature) const s = await getAuthStatus() setStatus(s) if (s.authenticated) onAuth() } catch (err) { - setError(String(err)) + setError(String(err instanceof Error ? err.message : err)) } finally { setLoading(false) } @@ -50,64 +52,83 @@ export function AuthPage({ onAuth }: Props) {

Авторизация в True API

- {status && ( + {hasPlugin === false && (
+ ⚠️ КриптоПро Browser Plugin не обнаружен. +
+ + Установите расширение для браузера + +
+ )} + + {status?.authenticated && ( +
- Статус:{' '} - {status.authenticated ? '✅ Авторизован' : '❌ Не авторизован'} - {status.tokenExpiresAt && ( -
- Токен истекает: {new Date(status.tokenExpiresAt).toLocaleString()} -
- )} + ✅ Авторизован +
+ Токен истекает: {new Date(status.tokenExpiresAt!).toLocaleString()} +
)}
- - - {dataToSign && ( -
- Данные для подписи:
- {dataToSign} + {hasPlugin && certs.length > 0 && ( +
+ +
)} - {uuid && ( - <> -