diff --git a/docs/API_KEYS_IMPLEMENTATION_PLAN.md b/docs/API_KEYS_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..c78bb4c --- /dev/null +++ b/docs/API_KEYS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,516 @@ +# 🔐 ДЕТАЛЬНЫЙ ПЛАН РЕАЛИЗАЦИИ СИСТЕМЫ API КЛЮЧЕЙ + +## 📋 ТРЕБОВАНИЯ + +1. **Множественные ключи** - организация может иметь несколько API ключей для разных приложений/магазинов +2. **Шифрование** - все ключи хранятся зашифрованными +3. **Функциональность** - ключи должны работать без задержек на расшифровку +4. **Безопасный UI** - никогда не показывать реальные ключи в интерфейсе +5. **Аудит** - логирование всех операций с ключами + +## 🏗️ АРХИТЕКТУРА + +### 1. НОВАЯ СХЕМА БД + +```prisma +// Основная таблица API ключей +model ApiKey { + id String @id @default(cuid()) + name String // Название ключа (например: "Основной магазин WB") + marketplace MarketplaceType + encryptedKey String // Зашифрованный ключ + encryptionIv String // Вектор инициализации + encryptionTag String // Тег аутентификации + keyFingerprint String // Последние 4 символа для идентификации + isActive Boolean @default(true) + isPrimary Boolean @default(false) // Основной ключ для маркетплейса + + // Метаданные + clientId String? // Для Ozon + validationData Json? // Результаты последней валидации + lastValidatedAt DateTime? + lastUsedAt DateTime? + usageCount Int @default(0) + + // Связи + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + createdById String + createdBy User @relation("ApiKeyCreator", fields: [createdById], references: [id]) + + // Аудит + auditLogs ApiKeyAudit[] + + // Временные метки + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + expiresAt DateTime? // Опциональный срок действия + + @@index([organizationId, marketplace]) + @@index([keyFingerprint]) + @@map("api_keys") +} + +// Аудит операций с ключами +model ApiKeyAudit { + id String @id @default(cuid()) + action ApiKeyAction + performedAt DateTime @default(now()) + performedBy String + user User @relation(fields: [performedBy], references: [id]) + apiKeyId String + apiKey ApiKey @relation(fields: [apiKeyId], references: [id]) + + // Контекст операции + ipAddress String? + userAgent String? + metadata Json? // Дополнительные данные + + @@index([apiKeyId]) + @@index([performedBy]) + @@map("api_key_audit") +} + +enum ApiKeyAction { + CREATED + UPDATED + VALIDATED + ACTIVATED + DEACTIVATED + DELETED + USED_FOR_SYNC + VALIDATION_FAILED +} +``` + +### 2. СЕРВИС ШИФРОВАНИЯ + +```typescript +// src/services/encryption-service.ts +import crypto from 'crypto' +import { LRUCache } from 'lru-cache' + +export class EncryptionService { + private algorithm = 'aes-256-gcm' + private keyDerivationSalt: Buffer + + // Кэш для расшифрованных ключей (время жизни: 5 минут) + private decryptedCache = new LRUCache({ + max: 100, + ttl: 1000 * 60 * 5, // 5 минут + dispose: (value) => { + // Очистка из памяти + if (value) { + crypto.randomFillSync(Buffer.from(value)) + } + }, + }) + + constructor() { + const masterKey = process.env.ENCRYPTION_MASTER_KEY + if (!masterKey) { + throw new Error('ENCRYPTION_MASTER_KEY not set') + } + + // Генерация ключа шифрования из мастер-ключа + this.keyDerivationSalt = Buffer.from(process.env.ENCRYPTION_SALT || 'sfera-api-keys-salt') + } + + private getDerivedKey(): Buffer { + const masterKey = process.env.ENCRYPTION_MASTER_KEY! + return crypto.pbkdf2Sync(masterKey, this.keyDerivationSalt, 10000, 32, 'sha256') + } + + encrypt(plainText: string): EncryptedData { + const key = this.getDerivedKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.algorithm, key, iv) + + let encrypted = cipher.update(plainText, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return { + encrypted, + iv: iv.toString('hex'), + tag: cipher.getAuthTag().toString('hex'), + } + } + + decrypt(encryptedData: EncryptedData): string { + // Проверяем кэш + const cacheKey = `${encryptedData.encrypted}-${encryptedData.iv}` + const cached = this.decryptedCache.get(cacheKey) + if (cached) { + return cached + } + + const key = this.getDerivedKey() + const decipher = crypto.createDecipheriv(this.algorithm, key, Buffer.from(encryptedData.iv, 'hex')) + + decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex')) + + let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // Сохраняем в кэш + this.decryptedCache.set(cacheKey, decrypted) + + return decrypted + } + + // Получение отпечатка ключа (последние 4 символа) + getFingerprint(apiKey: string): string { + return apiKey.slice(-4) + } + + // Генерация маски для UI + generateMask(fingerprint: string): string { + return `••••••••••••${fingerprint}` + } +} + +interface EncryptedData { + encrypted: string + iv: string + tag: string +} +``` + +### 3. ОБНОВЛЕННЫЙ СЕРВИС API КЛЮЧЕЙ + +```typescript +// src/services/api-key-service.ts +export class ApiKeyService { + constructor( + private prisma: PrismaClient, + private encryption: EncryptionService, + private marketplace: MarketplaceService, + ) {} + + async createApiKey(data: CreateApiKeyInput, userId: string): Promise { + // 1. Валидация ключа + const validation = await this.marketplace.validateApiKey(data.marketplace, data.apiKey, data.clientId) + + if (!validation.isValid) { + throw new Error(`Недействительный API ключ: ${validation.error}`) + } + + // 2. Шифрование + const encrypted = this.encryption.encrypt(data.apiKey) + const fingerprint = this.encryption.getFingerprint(data.apiKey) + + // 3. Сохранение + return this.prisma.$transaction(async (tx) => { + // Если это первый ключ для маркетплейса - делаем его основным + const existingCount = await tx.apiKey.count({ + where: { + organizationId: data.organizationId, + marketplace: data.marketplace, + isActive: true, + }, + }) + + const apiKey = await tx.apiKey.create({ + data: { + name: data.name, + marketplace: data.marketplace, + encryptedKey: encrypted.encrypted, + encryptionIv: encrypted.iv, + encryptionTag: encrypted.tag, + keyFingerprint: fingerprint, + clientId: data.clientId, + isPrimary: existingCount === 0, + organizationId: data.organizationId, + createdById: userId, + validationData: validation.data, + lastValidatedAt: new Date(), + }, + }) + + // 4. Аудит + await tx.apiKeyAudit.create({ + data: { + action: 'CREATED', + apiKeyId: apiKey.id, + performedBy: userId, + metadata: { + name: data.name, + marketplace: data.marketplace, + }, + }, + }) + + return apiKey + }) + } + + async getDecryptedKey(apiKeyId: string, userId: string): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { id: apiKeyId }, + }) + + if (!apiKey) { + throw new Error('API ключ не найден') + } + + // Аудит использования + await this.prisma.apiKeyAudit.create({ + data: { + action: 'USED_FOR_SYNC', + apiKeyId: apiKey.id, + performedBy: userId, + }, + }) + + // Обновляем статистику + await this.prisma.apiKey.update({ + where: { id: apiKeyId }, + data: { + lastUsedAt: new Date(), + usageCount: { increment: 1 }, + }, + }) + + // Расшифровка + return this.encryption.decrypt({ + encrypted: apiKey.encryptedKey, + iv: apiKey.encryptionIv, + tag: apiKey.encryptionTag, + }) + } + + async rotateKey(apiKeyId: string, newApiKey: string, userId: string): Promise { + // Валидация нового ключа + const apiKey = await this.prisma.apiKey.findUnique({ + where: { id: apiKeyId }, + }) + + if (!apiKey) throw new Error('API ключ не найден') + + const validation = await this.marketplace.validateApiKey(apiKey.marketplace, newApiKey, apiKey.clientId) + + if (!validation.isValid) { + throw new Error('Новый ключ недействителен') + } + + // Шифрование нового ключа + const encrypted = this.encryption.encrypt(newApiKey) + const fingerprint = this.encryption.getFingerprint(newApiKey) + + // Обновление + await this.prisma.$transaction(async (tx) => { + await tx.apiKey.update({ + where: { id: apiKeyId }, + data: { + encryptedKey: encrypted.encrypted, + encryptionIv: encrypted.iv, + encryptionTag: encrypted.tag, + keyFingerprint: fingerprint, + lastValidatedAt: new Date(), + }, + }) + + await tx.apiKeyAudit.create({ + data: { + action: 'UPDATED', + apiKeyId: apiKeyId, + performedBy: userId, + metadata: { reason: 'key_rotation' }, + }, + }) + }) + + // Очистка кэша + this.encryption.clearCache() + } +} +``` + +### 4. ОБНОВЛЕННЫЙ UI КОМПОНЕНТ + +```typescript +// src/components/settings/ApiKeysTab.tsx +export function ApiKeysTab() { + const [apiKeys, setApiKeys] = useState([]) + const [showAddModal, setShowAddModal] = useState(false) + const [editingKey, setEditingKey] = useState(null) + + return ( +
+ {/* Заголовок с кнопкой добавления */} +
+

API Ключи

+ +
+ + {/* Список ключей */} +
+ {apiKeys.map((apiKey) => ( + +
+
+
+

{apiKey.name}

+ + {apiKey.isPrimary ? 'Основной' : 'Дополнительный'} + + + {apiKey.isActive ? 'Активен' : 'Неактивен'} + +
+ +
+

+ Маркетплейс: {apiKey.marketplace} +

+

+ Ключ: {apiKey.maskedKey} +

+ {apiKey.lastUsedAt && ( +

+ Использован: {formatDate(apiKey.lastUsedAt)} +

+ )} +
+
+ +
+ {editingKey === apiKey.id ? ( + setEditingKey(null)} + onCancel={() => setEditingKey(null)} + /> + ) : ( + <> + + + + )} +
+
+
+ ))} +
+ + {/* Модальное окно добавления */} + {showAddModal && ( + setShowAddModal(false)} + onSuccess={() => { + setShowAddModal(false) + refetchApiKeys() + }} + /> + )} +
+ ) +} +``` + +### 5. GRAPHQL СХЕМА + +```graphql +type ApiKey { + id: ID! + name: String! + marketplace: MarketplaceType! + maskedKey: String! # Только маска ••••••••1234 + isActive: Boolean! + isPrimary: Boolean! + lastUsedAt: DateTime + lastValidatedAt: DateTime + createdAt: DateTime! + usageCount: Int! +} + +input CreateApiKeyInput { + name: String! + marketplace: MarketplaceType! + apiKey: String! + clientId: String # Для Ozon +} + +type Mutation { + createApiKey(input: CreateApiKeyInput!): ApiKeyResponse! + updateApiKey(id: ID!, newKey: String!): ApiKeyResponse! + toggleApiKeyStatus(id: ID!): ApiKeyResponse! + deleteApiKey(id: ID!): Boolean! + setPrimaryApiKey(id: ID!): ApiKeyResponse! +} + +type Query { + myApiKeys(marketplace: MarketplaceType): [ApiKey!]! + validateApiKey(marketplace: MarketplaceType!, apiKey: String!): ValidationResponse! +} +``` + +## 📋 ПОШАГОВЫЙ ПЛАН РЕАЛИЗАЦИИ + +### ЭТАП 1: Подготовка (1 день) + +1. ✅ Создать миграцию БД с новой схемой +2. ✅ Настроить переменные окружения для шифрования +3. ✅ Создать EncryptionService +4. ✅ Написать тесты для шифрования + +### ЭТАП 2: Бэкенд (2-3 дня) + +1. ✅ Создать ApiKeyService +2. ✅ Обновить GraphQL схему +3. ✅ Создать резолверы для API ключей +4. ✅ Добавить middleware для аудита +5. ✅ Миграция существующих ключей + +### ЭТАП 3: Интеграция (2 дня) + +1. ✅ Обновить WildberriesService для работы с новой системой +2. ✅ Обновить статистику селлеров +3. ✅ Добавить автоматическую ротацию ключей +4. ✅ Настроить мониторинг использования + +### ЭТАП 4: UI (2 дня) + +1. ✅ Создать компонент ApiKeysTab +2. ✅ Добавить модалки для добавления/редактирования +3. ✅ Интегрировать в user-settings +4. ✅ Добавить уведомления + +### ЭТАП 5: Тестирование (1 день) + +1. ✅ E2E тесты +2. ✅ Тесты безопасности +3. ✅ Нагрузочное тестирование + +## 🔒 МЕРЫ БЕЗОПАСНОСТИ + +1. **Никогда не логировать расшифрованные ключи** +2. **Автоматическая очистка кэша каждые 5 минут** +3. **Принудительная ротация ключей каждые 90 дней** +4. **Алерты при подозрительной активности** +5. **Ограничение количества ключей на организацию** + +## ⚡ ОПТИМИЗАЦИИ + +1. **LRU кэш для расшифрованных ключей** (5 минут TTL) +2. **Индексы БД по organizationId и marketplace** +3. **Batch загрузка ключей для статистики** +4. **Фоновая валидация ключей раз в сутки** diff --git a/docs/API_KEYS_SECURITY_PLAN.md b/docs/API_KEYS_SECURITY_PLAN.md new file mode 100644 index 0000000..aa1d106 --- /dev/null +++ b/docs/API_KEYS_SECURITY_PLAN.md @@ -0,0 +1,192 @@ +# 🔐 ПЛАН БЕЗОПАСНОСТИ API КЛЮЧЕЙ + +## ТЕКУЩИЕ ПРОБЛЕМЫ БЕЗОПАСНОСТИ + +1. **API ключи хранятся В ОТКРЫТОМ ВИДЕ** в БД ❌ +2. **Нет истории изменений** ключей ❌ +3. **Нет аудита доступа** к ключам ❌ +4. **Ключи видны в GraphQL ответах** ❌ + +## РЕКОМЕНДУЕМАЯ АРХИТЕКТУРА + +### 1. ШИФРОВАНИЕ КЛЮЧЕЙ + +```typescript +// services/crypto-service.ts +import crypto from 'crypto' + +export class CryptoService { + private algorithm = 'aes-256-gcm' + private secretKey = process.env.ENCRYPTION_KEY // 32 байта + + encrypt(text: string): { encrypted: string; iv: string; tag: string } { + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.algorithm, this.secretKey, iv) + + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + + const tag = cipher.getAuthTag() + + return { + encrypted, + iv: iv.toString('hex'), + tag: tag.toString('hex'), + } + } + + decrypt(encrypted: string, iv: string, tag: string): string { + const decipher = crypto.createDecipheriv(this.algorithm, this.secretKey, Buffer.from(iv, 'hex')) + + decipher.setAuthTag(Buffer.from(tag, 'hex')) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return decrypted + } +} +``` + +### 2. ОБНОВЛЕННАЯ СХЕМА БД + +```prisma +model ApiKey { + id String @id @default(cuid()) + marketplace MarketplaceType + encryptedKey String // Зашифрованный ключ + encryptionIv String // Вектор инициализации + encryptionTag String // Тег аутентификации + keyHash String // Хеш для быстрого сравнения + clientId String? + isActive Boolean @default(true) + lastUsedAt DateTime? + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + + // Аудит + createdById String + createdBy User @relation("ApiKeyCreatedBy", fields: [createdById], references: [id]) + lastModifiedById String? + lastModifiedBy User? @relation("ApiKeyModifiedBy", fields: [lastModifiedById], references: [id]) + + // История изменений + auditLogs ApiKeyAuditLog[] + + @@unique([organizationId, marketplace]) + @@index([keyHash]) + @@map("api_keys") +} + +model ApiKeyAuditLog { + id String @id @default(cuid()) + action ApiKeyAction // CREATED, UPDATED, VALIDATED, USED, DEACTIVATED + performedAt DateTime @default(now()) + performedBy String + user User @relation(fields: [performedBy], references: [id]) + apiKeyId String + apiKey ApiKey @relation(fields: [apiKeyId], references: [id]) + metadata Json? // Дополнительная информация + + @@index([apiKeyId]) + @@index([performedBy]) + @@map("api_key_audit_logs") +} + +enum ApiKeyAction { + CREATED + UPDATED + VALIDATED + USED + DEACTIVATED + REACTIVATED + DELETED +} +``` + +### 3. БЕЗОПАСНЫЕ РЕЗОЛВЕРЫ + +```typescript +// Сохранение ключа +const encrypted = cryptoService.encrypt(apiKey) +await prisma.apiKey.create({ + data: { + organizationId: user.organization.id, + marketplace, + encryptedKey: encrypted.encrypted, + encryptionIv: encrypted.iv, + encryptionTag: encrypted.tag, + keyHash: crypto.createHash('sha256').update(apiKey).digest('hex'), + createdById: context.user.id, + // Аудит + auditLogs: { + create: { + action: 'CREATED', + performedBy: context.user.id, + metadata: { ip: context.ip, userAgent: context.userAgent }, + }, + }, + }, +}) + +// Использование ключа +const apiKeyRecord = await prisma.apiKey.findUnique({ where: { id } }) +const decryptedKey = cryptoService.decrypt( + apiKeyRecord.encryptedKey, + apiKeyRecord.encryptionIv, + apiKeyRecord.encryptionTag, +) + +// Логирование использования +await prisma.apiKeyAuditLog.create({ + data: { + action: 'USED', + apiKeyId: apiKeyRecord.id, + performedBy: context.user.id, + metadata: { purpose: 'statistics_fetch' }, + }, +}) +``` + +### 4. ЗАЩИТА В GRAPHQL + +```typescript +// Никогда не возвращать расшифрованные ключи +type ApiKey { + id: ID! + marketplace: MarketplaceType! + isActive: Boolean! + lastUsedAt: DateTime + createdAt: DateTime! + # НЕ ВКЛЮЧАТЬ: apiKey, encryptedKey, encryptionIv, encryptionTag + # Показывать только маскированную версию + maskedKey: String! # "••••••••••1234" +} +``` + +### 5. ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ + +```env +# .env.local +ENCRYPTION_KEY=your-32-byte-base64-encoded-key-here # Генерировать: openssl rand -base64 32 +``` + +## ПЛАН МИГРАЦИИ + +1. **Создать CryptoService** +2. **Обновить схему БД** с новыми полями +3. **Миграция существующих ключей** (зашифровать) +4. **Обновить резолверы** для работы с шифрованием +5. **Добавить аудит** всех операций +6. **Обновить UI** для показа только маскированных ключей + +## ДОПОЛНИТЕЛЬНЫЕ МЕРЫ + +- Ротация ключей шифрования каждые 90 дней +- Автоматическое удаление неиспользуемых ключей +- Уведомления об аномальной активности +- Rate limiting для API операций +- Двухфакторная аутентификация для изменения ключей diff --git a/docs/API_KEYS_SIMPLE_PLAN.md b/docs/API_KEYS_SIMPLE_PLAN.md new file mode 100644 index 0000000..3d123d6 --- /dev/null +++ b/docs/API_KEYS_SIMPLE_PLAN.md @@ -0,0 +1,258 @@ +# 🔐 УПРОЩЕННЫЙ ПЛАН СИСТЕМЫ API КЛЮЧЕЙ + +## 📋 ТРЕБОВАНИЯ (УТОЧНЕННЫЕ) + +1. **Один ключ на маркетплейс** для организации +2. **Шифрование** всех ключей в БД +3. **Быстрый доступ** без задержек +4. **Безопасный UI** - только звездочки, не копируются +5. **Простота** - минимум сложности + +## 🏗️ АРХИТЕКТУРА + +### 1. ПРОСТАЯ СХЕМА БД + +```prisma +model ApiKey { + id String @id @default(cuid()) + marketplace MarketplaceType // WILDBERRIES или OZON + encryptedKey String // Зашифрованный ключ + encryptionIv String // Для расшифровки + encryptionTag String // Для проверки целостности + clientId String? // Только для Ozon + isActive Boolean @default(true) + + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, marketplace]) // Один ключ на маркетплейс + @@map("api_keys") +} +``` + +### 2. ПРОСТОЙ СЕРВИС ШИФРОВАНИЯ + +```typescript +// src/services/crypto.service.ts +import crypto from 'crypto' + +export class CryptoService { + private algorithm = 'aes-256-gcm' + private key: Buffer + + // Простой кэш для производительности + private cache = new Map() + + constructor() { + // Ключ из переменной окружения + const masterKey = process.env.ENCRYPTION_KEY + if (!masterKey || masterKey.length !== 64) { + throw new Error('ENCRYPTION_KEY must be 64 hex characters') + } + this.key = Buffer.from(masterKey, 'hex') + } + + encrypt(text: string): { encrypted: string; iv: string; tag: string } { + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv) + + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return { + encrypted, + iv: iv.toString('hex'), + tag: cipher.getAuthTag().toString('hex'), + } + } + + decrypt(encrypted: string, iv: string, tag: string): string { + // Проверяем кэш + const cacheKey = `${encrypted}-${iv}` + const cached = this.cache.get(cacheKey) + if (cached && cached.expires > Date.now()) { + return cached.value + } + + // Расшифровываем + const decipher = crypto.createDecipheriv(this.algorithm, this.key, Buffer.from(iv, 'hex')) + + decipher.setAuthTag(Buffer.from(tag, 'hex')) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // Сохраняем в кэш на 5 минут + this.cache.set(cacheKey, { + value: decrypted, + expires: Date.now() + 5 * 60 * 1000, + }) + + // Очищаем старые записи + this.cleanCache() + + return decrypted + } + + private cleanCache() { + const now = Date.now() + for (const [key, item] of this.cache) { + if (item.expires < now) { + this.cache.delete(key) + } + } + } +} +``` + +### 3. ОБНОВЛЕНИЕ РЕЗОЛВЕРА + +```typescript +// В wildberries resolver +import { CryptoService } from '../../../services/crypto.service' + +const cryptoService = new CryptoService() + +// При сохранении ключа +const encrypted = cryptoService.encrypt(apiKey) + +await prisma.apiKey.upsert({ + where: { + organizationId_marketplace: { + organizationId: user.organization.id, + marketplace: 'WILDBERRIES', + }, + }, + update: { + encryptedKey: encrypted.encrypted, + encryptionIv: encrypted.iv, + encryptionTag: encrypted.tag, + isActive: true, + }, + create: { + organizationId: user.organization.id, + marketplace: 'WILDBERRIES', + encryptedKey: encrypted.encrypted, + encryptionIv: encrypted.iv, + encryptionTag: encrypted.tag, + isActive: true, + }, +}) + +// При использовании ключа +const apiKeyRecord = await prisma.apiKey.findUnique({ + where: { + organizationId_marketplace: { + organizationId: user.organization.id, + marketplace: 'WILDBERRIES', + }, + }, +}) + +if (!apiKeyRecord || !apiKeyRecord.isActive) { + return { success: false, message: 'API ключ не найден' } +} + +const decryptedKey = cryptoService.decrypt( + apiKeyRecord.encryptedKey, + apiKeyRecord.encryptionIv, + apiKeyRecord.encryptionTag, +) + +const wbService = new WildberriesService(decryptedKey) +``` + +### 4. ОБНОВЛЕНИЕ USER-SETTINGS + +```typescript +// В handleSave добавить после сохранения профиля: + +// Сохраняем API ключи если они изменились +if (formData.wildberriesApiKey && formData.wildberriesApiKey !== '••••••••••••••••••••') { + await apolloClient.mutate({ + mutation: ADD_MARKETPLACE_API_KEY, + variables: { + input: { + marketplace: 'WILDBERRIES', + apiKey: formData.wildberriesApiKey, + validateOnly: false, + }, + }, + }) +} + +if (formData.ozonApiKey && formData.ozonApiKey !== '••••••••••••••••••••') { + await apolloClient.mutate({ + mutation: ADD_MARKETPLACE_API_KEY, + variables: { + input: { + marketplace: 'OZON', + apiKey: formData.ozonApiKey, + clientId: formData.ozonClientId, + validateOnly: false, + }, + }, + }) +} + +// Обновляем данные пользователя +await apolloClient.refetchQueries({ include: [GET_ME] }) +``` + +### 5. ЗАЩИТА UI + +```typescript +// В user-settings.tsx инпуты остаются как есть: + key.marketplace === 'WILDBERRIES') + ? '••••••••••••••••••••' // Показываем звездочки + : '' + } + onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)} + placeholder="Введите API ключ Wildberries" + readOnly={!isEditing} + className="glass-input" +/> +``` + +## 📋 ПРОСТОЙ ПЛАН РЕАЛИЗАЦИИ + +### ШАГ 1: Подготовка (2 часа) + +1. Создать миграцию БД +2. Генерировать ключ шифрования: `openssl rand -hex 32` +3. Добавить в .env: `ENCRYPTION_KEY=сгенерированный_ключ` + +### ШАГ 2: Бэкенд (4 часа) + +1. Создать CryptoService +2. Обновить резолвер addMarketplaceApiKey +3. Обновить резолвер getWildberriesStatistics +4. Миграция существующих ключей (если есть) + +### ШАГ 3: Фронтенд (2 часа) + +1. Обновить handleSave в user-settings +2. Проверить что звездочки не копируются +3. Тестирование + +## 🔒 БЕЗОПАСНОСТЬ + +1. **ENCRYPTION_KEY** - хранить в безопасном месте +2. **Не логировать** расшифрованные ключи +3. **HTTPS обязателен** для передачи ключей +4. **Резервные копии** ключа шифрования + +## ✅ ИТОГО + +- Простая схема без лишних полей +- Быстрое шифрование с кэшем +- Минимальные изменения в коде +- Сохранение через существующие инпуты +- Защита от просмотра реальных ключей diff --git a/docs/DEBUG_SELLER_STATISTICS.md b/docs/DEBUG_SELLER_STATISTICS.md new file mode 100644 index 0000000..0bc7054 --- /dev/null +++ b/docs/DEBUG_SELLER_STATISTICS.md @@ -0,0 +1,81 @@ +# 🔍 ДИАГНОСТИКА ПРОБЛЕМЫ /seller/statistics + +## 📊 ПРОБЛЕМА + +Страница статистики селлера не отображает данные + +## 🔴 КОРНЕВАЯ ПРИЧИНА + +**Отсутствует API ключ Wildberries в организации** + +### Детали: + +1. GraphQL резолвер `getWildberriesStatistics` проверяет наличие API ключа: + +```typescript +const apiKey = user.organization.apiKeys.find((key) => key.marketplace === 'WILDBERRIES' && key.isActive) + +if (!apiKey) { + return { + success: false, + message: 'API ключ Wildberries не найден', + data: [], + } +} +``` + +2. Компонент SalesTab корректно обрабатывает этот случай и показывает mock данные + +## ✅ РЕШЕНИЯ + +### ВАРИАНТ 1: Добавить API ключ в БД + +```sql +INSERT INTO "ApiKey" ( + "id", + "apiKey", + "marketplace", + "isActive", + "organizationId", + "createdAt" +) VALUES ( + gen_random_uuid(), + 'YOUR_WILDBERRIES_API_KEY', + 'WILDBERRIES', + true, + 'SELLER_ORGANIZATION_ID', + NOW() +); +``` + +### ВАРИАНТ 2: Использовать mock данные (временное решение) + +Изменить резолвер для возврата mock данных при отсутствии API ключа: + +```typescript +if (!apiKey) { + // Возвращаем mock данные для разработки + return { + success: true, + message: 'Using mock data (no API key)', + data: generateMockStatistics(dateFrom, dateTo), + } +} +``` + +### ВАРИАНТ 3: Добавить UI для настройки API ключа + +Создать страницу настроек для селлеров где они могут добавить свой API ключ Wildberries + +## 📋 ПРОВЕРКА + +1. Откройте Prisma Studio: `npx prisma studio` +2. Проверьте таблицу ApiKey +3. Убедитесь что у организации селлера есть активный ключ с marketplace = 'WILDBERRIES' + +## 🎯 СТАТУС + +- Архитектура: ✅ Работает +- GraphQL: ✅ Корректно настроен +- UI компоненты: ✅ Рендерятся без ошибок +- API интеграция: ❌ Требуется API ключ diff --git a/docs/FIX_API_KEYS_SAVING.md b/docs/FIX_API_KEYS_SAVING.md new file mode 100644 index 0000000..94c534e --- /dev/null +++ b/docs/FIX_API_KEYS_SAVING.md @@ -0,0 +1,77 @@ +# 🔧 ИСПРАВЛЕНИЕ СОХРАНЕНИЯ API КЛЮЧЕЙ + +## ПРОБЛЕМА + +В настройках пользователя (вкладка API) поля для API ключей есть, но они не сохраняются в БД. + +## ПРИЧИНА + +В функции `handleSave` в user-settings.tsx отсутствует логика для сохранения API ключей через мутацию `ADD_MARKETPLACE_API_KEY`. + +## РЕШЕНИЕ + +### 1. Добавить в handleSave после сохранения профиля: + +```typescript +// Сохраняем API ключи маркетплейсов +if (formData.wildberriesApiKey) { + await apolloClient.mutate({ + mutation: ADD_MARKETPLACE_API_KEY, + variables: { + input: { + marketplace: 'WILDBERRIES', + apiKey: formData.wildberriesApiKey, + validateOnly: false, + }, + }, + }) +} + +if (formData.ozonApiKey) { + await apolloClient.mutate({ + mutation: ADD_MARKETPLACE_API_KEY, + variables: { + input: { + marketplace: 'OZON', + apiKey: formData.ozonApiKey, + clientId: formData.ozonClientId, // если требуется + validateOnly: false, + }, + }, + }) +} +``` + +### 2. Импортировать мутацию: + +```typescript +import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN, ADD_MARKETPLACE_API_KEY } from '@/graphql/mutations' +``` + +### 3. Обновить GET_ME запрос после сохранения: + +```typescript +// Обновляем кэш с новыми API ключами +await apolloClient.refetchQueries({ + include: [GET_ME], +}) +``` + +## АЛЬТЕРНАТИВНОЕ РЕШЕНИЕ + +Создать отдельную страницу управления API ключами с: + +- Валидацией ключей перед сохранением +- Показом статуса интеграции +- Возможностью удаления ключей +- Тестированием соединения + +## ТЕКУЩИЙ СТАТУС ПОЛЬЗОВАТЕЛЕЙ + +Пользователи МОГУТ добавлять API ключи через: + +1. Регистрацию (если добавлена логика) +2. GraphQL мутацию напрямую +3. Prisma Studio (администратор) + +НО НЕ МОГУТ через UI настроек!