diff --git a/2025-09-18/API_KEYS_SECURITY_IMPLEMENTATION_PLAN.md b/2025-09-18/API_KEYS_SECURITY_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..932d39d --- /dev/null +++ b/2025-09-18/API_KEYS_SECURITY_IMPLEMENTATION_PLAN.md @@ -0,0 +1,488 @@ +# 🔐 ПЛАН РЕАЛИЗАЦИИ БЕЗОПАСНОЙ СИСТЕМЫ API КЛЮЧЕЙ + +> **Дата:** 2025-09-18 +> **Проект:** SFERA +> **Статус:** ГОТОВ К РЕАЛИЗАЦИИ + +## 📊 РЕЗУЛЬТАТЫ ДИАГНОСТИКИ + +### ✅ ТЕКУЩЕЕ СОСТОЯНИЕ СИСТЕМЫ: + +**Данные в БД:** +- 1 активный API ключ Wildberries (длина 398 символов) +- Организация "Rennel" типа SELLER +- Ключ создан: 2025-09-17T15:19:35.423Z +- **ПРОБЛЕМА:** Хранится в открытом виде в поле `apiKey` + +**UI Состояние:** +- ✅ Звездочки отображаются корректно: `••••••••••••••••••••` +- ✅ Логика переключения работает (редактирование очищает поле) +- ✅ Индикатор статуса показывает "API ключ настроен" +- ❌ **КРИТИЧНО:** handleSave НЕ СОХРАНЯЕТ API ключи + +**Безопасность:** +- ❌ Ключи хранятся в открытом виде в БД +- ❌ ENCRYPTION_KEY отсутствует в .env +- ⚠️ Нужно проверить копирование звездочек + +### 🎯 ЦЕЛИ РЕАЛИЗАЦИИ: + +1. **Безопасность:** Зашифровать существующий API ключ +2. **Функциональность:** Исправить сохранение в handleSave +3. **UX:** Защитить от копирования звездочек +4. **Производительность:** Кэш расшифровки на 2 часа + +--- + +## 🛡️ ПОЭТАПНЫЙ ПЛАН РЕАЛИЗАЦИИ + +### 📋 ЭТАП 1: ТЕСТИРОВАНИЕ КОПИРОВАНИЯ (10 мин) +**Цель:** Убедиться что звездочки не раскрывают реальный ключ + +**Задачи:** +- [ ] Запустить dev сервер +- [ ] Открыть /seller/settings в браузере +- [ ] Протестировать копирование поля с звездочками +- [ ] При необходимости добавить атрибуты блокировки + +**Ожидаемый результат:** Звездочки нельзя скопировать как реальный ключ + +**Критерии успеха:** +- ✅ При копировании получаются звездочки, а не настоящий ключ +- ✅ Невозможно выделить реальный ключ из поля + +--- + +### 🔧 ЭТАП 2: ИСПРАВЛЕНИЕ СОХРАНЕНИЯ (30 мин) +**Цель:** handleSave начинает сохранять API ключи в БД + +**Подготовка:** +```bash +# Backup текущего состояния +cp src/components/dashboard/user-settings.tsx src/components/dashboard/user-settings.tsx.backup +``` + +**Задачи:** +- [ ] Найти где обрезана функция handleSave (после строки 698) +- [ ] Добавить импорт ADD_MARKETPLACE_API_KEY в user-settings.tsx +- [ ] Добавить логику сохранения API ключей после сохранения профиля +- [ ] Протестировать сохранение через UI + +**Код изменений:** +```typescript +// В импортах добавить: +import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN, ADD_MARKETPLACE_API_KEY } from '@/graphql/mutations' + +// В 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 + } + } + }) +} + +// Обновляем кэш с новыми API ключами +await apolloClient.refetchQueries({ + include: [GET_ME] +}) +``` + +**Тестирование:** +- [ ] Ввести новый WB ключ → должен сохраниться в БД +- [ ] Проверить что ключ виден в GraphQL ответе +- [ ] Проверить работу статистики с новым ключом + +**Rollback план:** Восстановить из backup файла + +--- + +### 🔐 ЭТАП 3: СОЗДАНИЕ КРИПТОСЕРВИСА (45 мин) +**Цель:** Безопасное шифрование с кэшем на 2 часа + +**Подготовка:** +```bash +# Генерация мастер-ключа +openssl rand -hex 32 + +# Добавление в .env +echo "ENCRYPTION_KEY=сгенерированный_ключ" >> .env +``` + +**Задачи:** +- [ ] Создать `/src/services/crypto.service.ts` +- [ ] Реализовать AES-256-GCM шифрование +- [ ] Добавить кэш с TTL 2 часа +- [ ] Написать юнит-тесты + +**Реализация CryptoService:** +```typescript +// src/services/crypto.service.ts +import crypto from 'crypto' + +export class CryptoService { + private algorithm = 'aes-256-gcm' + private key: Buffer + + // Кэш на 2 часа + 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 { + // Проверяем кэш (2 часа TTL) + 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') + + // Сохраняем в кэш на 2 часа + this.cache.set(cacheKey, { + value: decrypted, + expires: Date.now() + 2 * 60 * 60 * 1000 // 2 часа + }) + + // Очищаем старые записи + 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) + } + } + } +} +``` + +**Тестирование:** +- [ ] Шифрование/расшифровка работает корректно +- [ ] Кэш сохраняет данные на 2 часа +- [ ] Автоочистка удаляет старые записи + +--- + +### 🗄️ ЭТАП 4: МИГРАЦИЯ БД (30 мин) +**Цель:** Добавить поля для зашифрованных ключей + +**Задачи:** +- [ ] Создать Prisma миграцию с новыми полями +- [ ] Применить миграцию к БД +- [ ] Проверить совместимость + +**Новая схема:** +```prisma +model ApiKey { + id String @id @default(cuid()) + marketplace MarketplaceType + + // Новые поля для шифрования + encryptedKey String? // Зашифрованный ключ + encryptionIv String? // Вектор инициализации + encryptionTag String? // Тег аутентификации + lastUsedAt DateTime? // Аудит использования + + // Старое поле (временно для совместимости) + apiKey String // УДАЛИТЬ после миграции + + clientId String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + validationData Json? + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + + @@unique([organizationId, marketplace]) + @@map("api_keys") +} +``` + +**Команды миграции:** +```bash +npx prisma migrate dev --name add_api_keys_encryption +npx prisma generate +``` + +**Тестирование:** +- [ ] Миграция применяется без ошибок +- [ ] Существующий ключ остается рабочим +- [ ] Новые поля готовы к использованию + +**Rollback:** `npx prisma migrate reset` (данные не критичны) + +--- + +### 🔄 ЭТАП 5: ОБНОВЛЕНИЕ РЕЗОЛВЕРОВ (45 мин) +**Цель:** Использовать шифрование в GraphQL + +**Задачи:** +- [ ] Обновить addMarketplaceApiKey для шифрования новых ключей +- [ ] Обновить getWildberriesStatistics для расшифровки +- [ ] Добавить fallback на старые ключи +- [ ] Протестировать API + +**Изменения в wildberries.ts:** +```typescript +import { CryptoService } from '../../../services/crypto.service' + +const cryptoService = new CryptoService() + +// В addMarketplaceApiKey: +if (existing) { + // Шифруем новый ключ + const encrypted = cryptoService.encrypt(apiKey) + + await prisma.apiKey.update({ + where: { id: existing.id }, + data: { + encryptedKey: encrypted.encrypted, + encryptionIv: encrypted.iv, + encryptionTag: encrypted.tag, + apiKey: null, // Очищаем старое поле + isActive: true + } + }) +} + +// В getWildberriesStatistics: +let decryptedKey: string + +if (apiKey.encryptedKey && apiKey.encryptionIv && apiKey.encryptionTag) { + // Новый зашифрованный ключ + decryptedKey = cryptoService.decrypt( + apiKey.encryptedKey, + apiKey.encryptionIv, + apiKey.encryptionTag + ) +} else if (apiKey.apiKey) { + // Fallback на старый незашифрованный ключ + decryptedKey = apiKey.apiKey +} else { + return { success: false, message: 'API ключ не найден' } +} +``` + +**Тестирование:** +- [ ] Новые ключи шифруются при сохранении +- [ ] Старый ключ работает через fallback +- [ ] Статистика загружается корректно +- [ ] Кэш работает (повторные запросы быстрее) + +--- + +### 📦 ЭТАП 6: МИГРАЦИЯ СУЩЕСТВУЮЩЕГО КЛЮЧА (15 мин) +**Цель:** Перешифровать единственный ключ в БД + +**Задачи:** +- [ ] Создать скрипт миграции +- [ ] Выполнить перешифровку +- [ ] Убрать fallback логику +- [ ] Финальное тестирование + +**Скрипт миграции:** +```javascript +// scripts/migrate-existing-api-keys.js +import { PrismaClient } from '@prisma/client' +import { CryptoService } from '../src/services/crypto.service.js' + +const prisma = new PrismaClient() +const cryptoService = new CryptoService() + +async function migrateApiKeys() { + const keys = await prisma.apiKey.findMany({ + where: { + apiKey: { not: null }, + encryptedKey: null + } + }) + + console.log(`🔄 Найдено ключей для миграции: ${keys.length}`) + + for (const key of keys) { + if (!key.apiKey) continue + + const encrypted = cryptoService.encrypt(key.apiKey) + + await prisma.apiKey.update({ + where: { id: key.id }, + data: { + encryptedKey: encrypted.encrypted, + encryptionIv: encrypted.iv, + encryptionTag: encrypted.tag, + apiKey: null // Очищаем старое поле + } + }) + + console.log(`✅ Мигрирован ключ ${key.id} для ${key.marketplace}`) + } + + console.log('🎉 Миграция завершена') +} + +migrateApiKeys().catch(console.error) +``` + +**Выполнение:** +```bash +node scripts/migrate-existing-api-keys.js +``` + +**Тестирование:** +- [ ] Существующий ключ перешифрован +- [ ] Статистика продолжает работать +- [ ] В БД нет незашифрованных ключей +- [ ] Fallback больше не используется + +--- + +## 🧪 ПЛАН ТЕСТИРОВАНИЯ + +### После каждого этапа проверять: + +**1. Основная функциональность:** +- [ ] Авторизация в кабинет селлера работает +- [ ] Настройки → API ключи открываются +- [ ] Статистика Wildberries загружается + +**2. Сохранение ключей:** +- [ ] Ввод нового WB ключа сохраняется +- [ ] Настройки сохраняются без ошибок +- [ ] Ключ появляется в БД + +**3. Безопасность:** +- [ ] Звездочки показываются вместо ключей +- [ ] Копирование не раскрывает реальные ключи +- [ ] GraphQL не возвращает расшифрованные ключи +- [ ] Ошибки не логируют реальные ключи + +**4. Производительность:** +- [ ] Первый запрос статистики (расшифровка) +- [ ] Повторный запрос (из кэша) быстрее +- [ ] Память не растет при множественных запросах + +--- + +## ⚠️ КРИТИЧЕСКИЕ МОМЕНТЫ + +### 🚨 ТОЧКИ ОСТАНОВКИ: +- Статистика перестала загружаться +- Ошибки сохранения в настройках +- Потеря существующего API ключа +- Любые 500 ошибки в GraphQL +- Ошибки шифрования/расшифровки + +### 🔄 ROLLBACK ПЛАНЫ: + +**Этап 2:** Восстановить user-settings.tsx из backup +**Этап 3:** Не применять к продакшену +**Этап 4:** `npx prisma migrate reset` +**Этап 5:** Откатить изменения резолверов +**Этап 6:** Восстановить из backup БД + +### 💾 BACKUP ФАЙЛЫ: +- ✅ `api_keys_backup_1758196936364.json` - данные БД +- ✅ `user-settings.tsx.backup` - будет создан на этапе 2 + +--- + +## 🎯 ОЖИДАЕМЫЙ РЕЗУЛЬТАТ + +### После завершения всех этапов: + +**✅ Безопасность:** +- API ключи зашифрованы в БД алгоритмом AES-256-GCM +- UI показывает только звездочки +- Копирование не раскрывает реальные ключи +- GraphQL не возвращает расшифрованные ключи + +**✅ Функциональность:** +- Сохранение API ключей через UI работает +- Статистика Wildberries загружается корректно +- Существующий ключ мигрирован без потерь + +**✅ Производительность:** +- Кэш расшифровки на 2 часа +- Автоочистка старых записей кэша +- Быстрые повторные запросы + +**✅ Аудит:** +- Поле lastUsedAt отслеживает использование +- Логи не содержат реальных ключей +- Безопасное хранение в production + +--- + +## 📋 ЧЕКЛИСТ ГОТОВНОСТИ + +Перед началом убедиться: +- [ ] Проект запускается локально +- [ ] БД доступна и работает +- [ ] Есть доступ к кабинету селлера +- [ ] Backup данных создан +- [ ] ENCRYPTION_KEY сгенерирован + +**План готов к исполнению! 🚀** + +--- + +*Документ создан: 2025-09-18* +*Проект: SFERA Marketplace Platform* +*Статус: Готов к реализации* \ No newline at end of file diff --git a/2025-09-18/ARCHITECTURE_IMPROVEMENT_PLAN.md b/2025-09-18/ARCHITECTURE_IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..ac66850 --- /dev/null +++ b/2025-09-18/ARCHITECTURE_IMPROVEMENT_PLAN.md @@ -0,0 +1,304 @@ +# 📋 ПЛАН УЛУЧШЕНИЯ АРХИТЕКТУРЫ АВТОРИЗАЦИИ И БЕЗОПАСНОСТИ SFERA + +**Дата создания:** 2025-09-18 +**Автор:** Claude AI + Вероника Смирнова +**Статус:** В разработке + +## 📊 Анализ текущего состояния + +### ✅ Что уже реализовано и работает: + +1. **Безопасные ID (CUID)** + - Все модели используют `@default(cuid())` + - Невозможно угадать ID других организаций + - Пример: `cmfpe46iv0001y51d87f4vy2n` + +2. **Защита типов кабинетов** + - `useRoleGuard` на 49 страницах + - Автоматический редирект при попытке доступа к чужому типу кабинета + - Селлер не может зайти в кабинет Поставщика + +3. **Изоляция данных на уровне API** + - Все резолверы фильтруют по `organizationId` + - Каждая организация видит только свои данные + - Проверки в каждом GraphQL резолвере + +4. **Структура роутинга** + - Четкое разделение: `/seller/*`, `/fulfillment/*`, `/logistics/*`, `/wholesale/*` + - DashboardHome автоматически направляет в нужный кабинет + +### 🚨 Выявленные проблемы: + +1. **Отсутствие глобального состояния (AuthContext)** + - Каждый компонент создает свой экземпляр useAuth + - Состояние не синхронизируется между компонентами + - Sidebar не видит данные пользователя после авторизации + +2. **Дублирование кода безопасности** + - Одинаковые проверки в 50+ резолверах + - Риск забыть добавить проверку в новый резолвер + - Сложность поддержки + +3. **Отсутствие персистентности** + - При обновлении страницы состояние теряется + - Повторные запросы GET_ME + - Плохой UX при F5 + +4. **Нет серверной проверки роутов** + - Проверки только на уровне компонентов + - Страница начинает загружаться до проверки прав + +## 🎯 План улучшений + +### ФАЗА 1: Критические исправления + +#### 1. Реализация AuthContext [🔴 КРИТИЧНО] + +**Срок:** 2-3 часа +**Приоритет:** Максимальный +**Влияние:** Решает проблему с sidebar и синхронизацией состояния + +**План реализации:** + +1. Создать `/src/contexts/AuthContext.tsx`: +```typescript +import { createContext, useContext, useState, useEffect } from 'react' +import { useApolloClient } from '@apollo/client' + +interface AuthContextType { + user: User | null + isAuthenticated: boolean + isLoading: boolean + checkAuth: () => Promise + login: (phone: string, code: string) => Promise + logout: () => void +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + // Перенести всю логику из текущего useAuth + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // ... вся логика авторизации + + return ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within AuthProvider') + } + return context +} +``` + +2. Обновить `/src/app/providers.tsx`: +```typescript +export function Providers({ children }) { + return ( + + + + {children} + + + + ) +} +``` + +3. Убрать временные решения: + - Удалить `useQuery(GET_ME)` из AppShell + - Убрать передачу user через props + - Вернуть оригинальную логику компонентов + +#### 2. GraphQL Middleware для безопасности [🟡 ВАЖНО] + +**Срок:** 1 день +**Приоритет:** Высокий +**Влияние:** Централизованная безопасность, чистый код + +**План реализации:** + +1. Создать `/src/graphql/middleware/organizationAccess.ts`: +```typescript +export const organizationAccessMiddleware = async ( + resolve, + parent, + args, + context, + info +) => { + // Пропускаем публичные операции + const PUBLIC_OPERATIONS = ['login', 'sendSmsCode', 'verifySmsCode'] + if (PUBLIC_OPERATIONS.includes(info.fieldName)) { + return resolve(parent, args, context, info) + } + + // Проверяем авторизацию + if (!context.user?.organizationId) { + throw new GraphQLError('Unauthorized', { + extensions: { code: 'UNAUTHENTICATED' } + }) + } + + // Автоматически добавляем organizationId к запросам + if (args.where && typeof args.where === 'object') { + args.where.organizationId = context.user.organizationId + } + + // Логирование для безопасности + console.log(`[${context.user.organizationId}] ${info.fieldName}`, { + userId: context.user.id, + operation: info.fieldName, + timestamp: new Date().toISOString() + }) + + return resolve(parent, args, context, info) +} +``` + +2. Применить middleware ко всем резолверам +3. Удалить дублированные проверки из резолверов + +### ФАЗА 2: Важные улучшения + +#### 3. Персистентность состояния [🟡 ВАЖНО] + +**Срок:** 0.5 дня +**Приоритет:** Средний +**Влияние:** Улучшение UX при обновлении страницы + +**План реализации:** + +1. Добавить в AuthContext сохранение состояния: +```typescript +// При успешной авторизации +const encryptedUser = encrypt(JSON.stringify(user)) +localStorage.setItem('auth:user', encryptedUser) + +// При инициализации +const savedUser = localStorage.getItem('auth:user') +if (savedUser) { + const user = JSON.parse(decrypt(savedUser)) + // Проверить валидность токена +} +``` + +2. Реализовать refresh token механизм +3. Очистка при logout + +#### 4. Next.js Middleware [🟢 ЖЕЛАТЕЛЬНО] + +**Срок:** 1 день +**Приоритет:** Средний +**Влияние:** Производительность, серверная защита + +**План реализации:** + +1. Создать `/src/middleware.ts`: +```typescript +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { jwtVerify } from 'jose' + +export async function middleware(request: NextRequest) { + const token = request.cookies.get('auth-token')?.value + const pathname = request.nextUrl.pathname + + // Публичные маршруты + const publicPaths = ['/', '/login', '/register'] + if (publicPaths.includes(pathname)) return + + // Проверка токена + if (!token) { + return NextResponse.redirect(new URL('/login', request.url)) + } + + try { + const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET!)) + const orgType = payload.organization?.type + + // Проверка доступа к кабинету + if (pathname.startsWith('/seller') && orgType !== 'SELLER') { + return NextResponse.redirect(new URL('/dashboard', request.url)) + } + // ... остальные проверки + + } catch (error) { + return NextResponse.redirect(new URL('/login', request.url)) + } +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] +} +``` + +### ФАЗА 3: Оптимизации + +#### 5. Улучшение загрузки [🔵 ОПЦИОНАЛЬНО] + +**Срок:** 1 неделя +**Приоритет:** Низкий +**Влияние:** Улучшение UX + +- Skeleton screens для всех страниц +- Prefetch критических данных +- Оптимистичные обновления UI +- PWA функциональность + +#### 6. Расширенная система прав [🔵 ОПЦИОНАЛЬНО] + +**Срок:** 2 недели +**Приоритет:** Низкий +**Влияние:** Enterprise функциональность + +- Роли: Admin, Manager, Viewer +- Permissions: canEdit, canDelete, canApprove +- Аудит всех действий +- Делегирование доступа + +## 📈 Ожидаемые результаты + +### После ФАЗЫ 1: +- ✅ Sidebar работает стабильно +- ✅ Состояние синхронизировано между компонентами +- ✅ Единая точка контроля безопасности +- ✅ Чистый, поддерживаемый код + +### После ФАЗЫ 2: +- ✅ Мгновенная загрузка после F5 +- ✅ Защита на уровне сервера +- ✅ Автоматическое управление сессией + +### После ФАЗЫ 3: +- ✅ Премиум UX +- ✅ Enterprise-ready система +- ✅ Готовность к масштабированию + +## 🚀 Рекомендуемый порядок выполнения + +1. **Неделя 1:** AuthContext (решает критическую проблему) +2. **Неделя 2:** GraphQL Middleware + Персистентность +3. **Месяц 2:** Next.js Middleware + Оптимизации по необходимости + +## 📝 Критерии успеха + +- [ ] Sidebar отображается сразу после авторизации +- [ ] Нет дублирования состояния между компонентами +- [ ] Единая проверка безопасности для всех API запросов +- [ ] Состояние сохраняется при обновлении страницы +- [ ] Производительность не ухудшилась +- [ ] Код стал чище и проще в поддержке + +--- + +*Документ будет обновляться по мере реализации плана* \ No newline at end of file diff --git a/2025-09-18/AUTH_CONTEXT_MIGRATION_PLAN.md b/2025-09-18/AUTH_CONTEXT_MIGRATION_PLAN.md new file mode 100644 index 0000000..d75f02a --- /dev/null +++ b/2025-09-18/AUTH_CONTEXT_MIGRATION_PLAN.md @@ -0,0 +1,505 @@ +# 🔐 ПЛАН БЕЗОПАСНОЙ МИГРАЦИИ НА AuthContext + +**Дата создания:** 2025-09-18 +**Автор:** Claude AI + Вероника Смирнова +**Статус:** Готов к реализации + +## 📊 Результаты глубокой диагностики + +### Анализ текущей реализации useAuth + +**Размер и сложность:** +- 657 строк кода +- 65 файлов используют useAuth() +- Сложная логика с rollback механизмами +- Интеграция с Apollo Client через localStorage + +**Ключевые компоненты:** +1. **State Management** + - `user: User | null` - данные пользователя + - `isAuthenticated: boolean` - статус авторизации + - `isLoading: boolean` - индикатор загрузки + - `isCheckingAuth: boolean` - защита от дублирования + +2. **Методы авторизации** + - `sendSmsCode` - отправка SMS + - `verifySmsCode` - проверка кода + - `checkAuth` - проверка текущей сессии + - `logout` - выход + +3. **Методы регистрации** + - `registerFulfillmentOrganization` + - `registerSellerOrganization` + - `registerOrganization` (универсальный) + +4. **Интеграции** + - Apollo Client для GraphQL + - localStorage для токенов + - refreshApolloClient для синхронизации + +### Выявленные проблемы + +1. **Множественные экземпляры состояния** + ``` + AppShell → useAuth() → useState (копия 1) + Sidebar → useAuth() → useState (копия 2) + Component → useAuth() → useState (копия 3) + ``` + +2. **Race conditions** + - checkAuth вызывается параллельно из разных компонентов + - isCheckingAuth защищает только локальный экземпляр + +3. **Отсутствие синхронизации** + - Обновления в одном компоненте не видны в других + - GET_ME выполняется многократно + +4. **Проблемы с SSR** + - Прямое обращение к localStorage + - window checks разбросаны по коду + +## 🎯 Архитектура решения + +### Новая структура с AuthContext + +``` +AuthProvider (глобальное состояние) + ├── Apollo Provider + │ └── Auth Link (токены из контекста) + ├── State Management + │ ├── user + │ ├── isAuthenticated + │ └── isLoading + └── Methods + ├── Authentication + ├── Registration + └── Session Management +``` + +### Преимущества + +1. **Единое состояние** - все компоненты видят одни данные +2. **Оптимизация запросов** - GET_ME выполняется 1 раз +3. **Синхронизация** - изменения видны везде мгновенно +4. **SSR совместимость** - централизованные проверки +5. **Типобезопасность** - строгая типизация контекста + +## 📋 Поэтапный план миграции + +### ЭТАП 0: Подготовка [30 мин] + +**Цель:** Создать безопасную среду для миграции + +1. **Создать backup текущего состояния** + ```bash + git add . + git commit -m "backup: перед миграцией на AuthContext" + git branch backup-before-auth-context + ``` + +2. **Создать feature branch** + ```bash + git checkout -b feature/auth-context-migration + ``` + +3. **Подготовить структуру папок** + ``` + src/ + ├── contexts/ + │ └── auth/ + │ ├── AuthContext.tsx # Основной контекст + │ ├── AuthProvider.tsx # Provider компонент + │ ├── types.ts # TypeScript типы + │ └── utils.ts # Вспомогательные функции + ``` + +### ЭТАП 1: Создание AuthContext с минимальной функциональностью [1 час] + +**Цель:** Создать работающий контекст без нарушения существующего функционала + +1. **Создать типы** (`src/contexts/auth/types.ts`) + ```typescript + export interface User { + id: string + phone: string + avatar?: string + managerName?: string + organization?: Organization + } + + export interface Organization { + id: string + inn: string + type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE' + // ... остальные поля + } + + export interface AuthState { + user: User | null + isAuthenticated: boolean + isLoading: boolean + } + + export interface AuthContextType extends AuthState { + // Методы будем добавлять постепенно + checkAuth: () => Promise + logout: () => void + } + ``` + +2. **Создать контекст** (`src/contexts/auth/AuthContext.tsx`) + ```typescript + import { createContext } from 'react' + import type { AuthContextType } from './types' + + export const AuthContext = createContext(null) + ``` + +3. **Создать базовый Provider** (`src/contexts/auth/AuthProvider.tsx`) + ```typescript + import { useState, useCallback, useEffect } from 'react' + import { AuthContext } from './AuthContext' + import { getAuthToken, removeAuthToken } from '@/lib/apollo-client' + + export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + // Минимальная реализация checkAuth + const checkAuth = useCallback(async () => { + const token = getAuthToken() + if (!token) { + setIsAuthenticated(false) + setUser(null) + setIsLoading(false) + return + } + + // TODO: Добавить GET_ME запрос + setIsLoading(false) + }, []) + + // Минимальная реализация logout + const logout = useCallback(() => { + removeAuthToken() + setUser(null) + setIsAuthenticated(false) + window.location.href = '/' + }, []) + + useEffect(() => { + checkAuth() + }, [checkAuth]) + + const value = { + user, + isAuthenticated, + isLoading, + checkAuth, + logout + } + + return ( + + {children} + + ) + } + ``` + +4. **Создать временный хук-обертку** (`src/hooks/useAuth.ts`) + ```typescript + // В начале файла добавляем + import { useContext } from 'react' + import { AuthContext } from '@/contexts/auth/AuthContext' + + // Временный флаг для постепенной миграции + const USE_AUTH_CONTEXT = false + + export const useAuth = (): UseAuthReturn => { + if (USE_AUTH_CONTEXT) { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within AuthProvider') + } + + // Адаптер для совместимости API + return { + ...context, + // Методы-заглушки для совместимости + sendSmsCode: async () => ({ success: false, message: 'Not implemented' }), + verifySmsCode: async () => ({ success: false, message: 'Not implemented' }), + registerFulfillmentOrganization: async () => ({ success: false, message: 'Not implemented' }), + registerSellerOrganization: async () => ({ success: false, message: 'Not implemented' }), + registerOrganization: async () => ({ success: false, message: 'Not implemented' }), + updateUser: () => {} + } + } + + // Существующая реализация остается без изменений + // ... весь текущий код + } + ``` + +### ЭТАП 2: Тестирование базовой интеграции [30 мин] + +**Цель:** Убедиться что ничего не сломалось + +1. **Добавить AuthProvider в providers.tsx** + ```typescript + import { AuthProvider } from '@/contexts/auth/AuthProvider' + + export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) + } + ``` + +2. **Включить USE_AUTH_CONTEXT для одного компонента** + - Начать с простого компонента (например, UserProfile в sidebar) + - Проверить что компонент рендерится + - Проверить что нет ошибок в консоли + +3. **Rollback план** + - Если есть ошибки - установить USE_AUTH_CONTEXT = false + - Исправить проблемы + - Повторить тестирование + +### ЭТАП 3: Миграция основного функционала [2 часа] + +**Цель:** Перенести всю логику в AuthContext + +1. **Перенести checkAuth с GET_ME** + ```typescript + const checkAuth = useCallback(async () => { + if (isCheckingAuth.current) return + + const token = getAuthToken() + if (!token) { + setIsAuthenticated(false) + setUser(null) + setIsLoading(false) + return + } + + isCheckingAuth.current = true + setIsLoading(true) + + try { + const { data } = await apolloClient.query({ + query: GET_ME, + errorPolicy: 'all', + fetchPolicy: 'network-only' + }) + + if (data?.me) { + setUser(data.me) + setIsAuthenticated(true) + setUserData(data.me) + } + } catch (error) { + // Обработка ошибок + } finally { + isCheckingAuth.current = false + setIsLoading(false) + } + }, []) + ``` + +2. **Перенести SMS методы** + - sendSmsCode + - verifySmsCode + - Сохранить всю логику с логированием + +3. **Перенести методы регистрации** + - registerFulfillmentOrganization + - registerSellerOrganization + - registerOrganization + - Сохранить rollback механизмы + +4. **Добавить updateUser** + ```typescript + const updateUser = useCallback((updatedUser: Partial) => { + setUser(current => { + if (!current) return current + const updated = { ...current, ...updatedUser } + setUserData(updated) // Синхронизация с localStorage + return updated + }) + }, []) + ``` + +### ЭТАП 4: Постепенная миграция компонентов [1 день] + +**Цель:** Безопасно перевести все компоненты на новую систему + +1. **Приоритетные компоненты** (первая очередь) + - AppShell + - Sidebar и все его варианты + - AuthGuard + +2. **Критические компоненты** (вторая очередь) + - Страницы авторизации (login, register) + - DashboardHome + - useRoleGuard + +3. **Остальные компоненты** (третья очередь) + - Разбить на группы по 10-15 файлов + - Мигрировать группами + - Тестировать после каждой группы + +**Процесс для каждого компонента:** +1. Включить USE_AUTH_CONTEXT локально +2. Проверить функциональность +3. Если работает - коммит +4. Если нет - откат и исправление + +### ЭТАП 5: Оптимизация и очистка [1 час] + +**Цель:** Удалить старый код и оптимизировать + +1. **Удалить старую реализацию из useAuth** + - Оставить только обертку для контекста + - Удалить локальные useState + - Удалить дублированную логику + +2. **Оптимизировать рендеринг** + ```typescript + // Мемоизация значения контекста + const value = useMemo(() => ({ + user, + isAuthenticated, + isLoading, + checkAuth, + logout, + // ... другие методы + }), [user, isAuthenticated, isLoading]) + ``` + +3. **Добавить DevTools** + ```typescript + if (process.env.NODE_ENV === 'development') { + (window as any).__AUTH_STATE__ = { user, isAuthenticated } + } + ``` + +### ЭТАП 6: Финальное тестирование [1 час] + +**Цель:** Убедиться что все работает + +1. **Функциональные тесты** + - [ ] Авторизация по SMS + - [ ] Регистрация всех типов организаций + - [ ] Отображение sidebar + - [ ] Переходы между страницами + - [ ] Выход из системы + - [ ] Обновление страницы (F5) + +2. **Тесты производительности** + - [ ] Нет множественных GET_ME запросов + - [ ] Нет лишних ре-рендеров + - [ ] Быстрая загрузка после F5 + +3. **Регрессионные тесты** + - [ ] Все 65 компонентов работают + - [ ] API ключи сохраняются + - [ ] Роутинг по типам организаций + +## 🚨 Риски и митигация + +### Риск 1: Поломка авторизации +**Митигация:** +- Постепенная миграция через флаг USE_AUTH_CONTEXT +- Возможность быстрого отката +- Тестирование на каждом этапе + +### Риск 2: Потеря состояния +**Митигация:** +- Сохранение в localStorage остается +- Rollback механизмы сохраняются +- Логирование всех изменений + +### Риск 3: Проблемы с SSR +**Митигация:** +- Все проверки window в одном месте +- useEffect для клиентских операций +- Правильная инициализация состояния + +### Риск 4: Race conditions +**Митигация:** +- useRef для флагов загрузки +- Отмена дублированных запросов +- Правильная очередность операций + +## 📊 Метрики успеха + +1. **Функциональность** + - ✅ Все 65 компонентов работают + - ✅ Sidebar отображается корректно + - ✅ Авторизация стабильна + +2. **Производительность** + - ✅ GET_ME вызывается 1 раз + - ✅ Нет задержек при навигации + - ✅ Быстрая загрузка после F5 + +3. **Качество кода** + - ✅ Единое место управления состоянием + - ✅ Типобезопасность + - ✅ Отсутствие дублирования + +## 🔄 Rollback план + +Если что-то пойдет не так на любом этапе: + +1. **Быстрый откат** + ```bash + git checkout backup-before-auth-context + ``` + +2. **Частичный откат** + - Установить USE_AUTH_CONTEXT = false + - Вернуть проблемные компоненты на старую версию + - Исправить проблемы в изолированной ветке + +3. **Восстановление данных** + - localStorage сохраняется + - Токены остаются валидными + - Пользователи не заметят проблем + +## 📝 Чек-лист готовности + +Перед началом миграции убедитесь: + +- [ ] Создан backup текущего состояния +- [ ] Команда предупреждена о работах +- [ ] Подготовлен план коммуникации при проблемах +- [ ] Есть доступ к логам и мониторингу +- [ ] Определено время для миграции (лучше в период низкой активности) + +## 🎯 Ожидаемый результат + +После завершения миграции: + +1. **Немедленные улучшения** + - Sidebar работает стабильно + - Состояние синхронизировано между компонентами + - Уменьшено количество запросов к API + +2. **Долгосрочные преимущества** + - Готовность к добавлению новых функций + - Упрощенная отладка + - Лучшая производительность + - Возможность добавления продвинутых функций (персистентность, refresh tokens) + +--- + +*Документ будет обновляться по ходу выполнения миграции* \ No newline at end of file diff --git a/src/components/auth-guard.tsx b/src/components/auth-guard.tsx index a31d5f5..659381d 100644 --- a/src/components/auth-guard.tsx +++ b/src/components/auth-guard.tsx @@ -28,7 +28,7 @@ export function AuthGuard({ children, fallback }: AuthGuardProps) { } initAuth() - }, [checkAuth, isAuthenticated, user]) // Добавляем зависимости как требует линтер + }, []) // Запускаем только один раз при монтировании // Показываем лоадер пока проверяем авторизацию diff --git a/src/components/dashboard/sidebar/FulfillmentSidebar.tsx b/src/components/dashboard/sidebar/FulfillmentSidebar.tsx index c9a01ad..91139c0 100644 --- a/src/components/dashboard/sidebar/FulfillmentSidebar.tsx +++ b/src/components/dashboard/sidebar/FulfillmentSidebar.tsx @@ -24,8 +24,11 @@ function FulfillmentSuppliesNotification({ count }: { count: number }) { ) } -export function FulfillmentSidebar() { - const { user, logout } = useAuth() +export function FulfillmentSidebar({ user: propUser }: { user?: any } = {}) { + const { user: hookUser, logout } = useAuth() + + // Приоритет: переданный user через props, затем из хука + const user = propUser || hookUser const router = useRouter() const pathname = usePathname() const { isCollapsed, toggleSidebar } = useSidebar() diff --git a/src/components/dashboard/sidebar/LogistSidebar.tsx b/src/components/dashboard/sidebar/LogistSidebar.tsx index d277882..62e997b 100644 --- a/src/components/dashboard/sidebar/LogistSidebar.tsx +++ b/src/components/dashboard/sidebar/LogistSidebar.tsx @@ -13,8 +13,11 @@ import { UserProfile } from './core/UserProfile' import { useSidebarData } from './hooks/useSidebarData' import { logistNavigation } from './navigations/logist' -export function LogistSidebar() { - const { user, logout } = useAuth() +export function LogistSidebar({ user: propUser }: { user?: any } = {}) { + const { user: hookUser, logout } = useAuth() + + // Приоритет: переданный user через props, затем из хука + const user = propUser || hookUser const router = useRouter() const pathname = usePathname() const { isCollapsed, toggleSidebar } = useSidebar() diff --git a/src/components/dashboard/sidebar/SellerSidebar.tsx b/src/components/dashboard/sidebar/SellerSidebar.tsx index 4a9dbf5..96fe56c 100644 --- a/src/components/dashboard/sidebar/SellerSidebar.tsx +++ b/src/components/dashboard/sidebar/SellerSidebar.tsx @@ -13,8 +13,11 @@ import { UserProfile } from './core/UserProfile' import { useSidebarData } from './hooks/useSidebarData' import { sellerNavigation } from './navigations/seller' -export function SellerSidebar() { - const { user, logout } = useAuth() +export function SellerSidebar({ user: propUser }: { user?: any } = {}) { + const { user: hookUser, logout } = useAuth() + + // Приоритет: переданный user через props, затем из хука + const user = propUser || hookUser const router = useRouter() const pathname = usePathname() const { isCollapsed, toggleSidebar } = useSidebar() diff --git a/src/components/dashboard/sidebar/WholesaleSidebar.tsx b/src/components/dashboard/sidebar/WholesaleSidebar.tsx index 4754ea2..8c015c6 100644 --- a/src/components/dashboard/sidebar/WholesaleSidebar.tsx +++ b/src/components/dashboard/sidebar/WholesaleSidebar.tsx @@ -24,8 +24,11 @@ function WholesaleOrdersNotification({ count }: { count: number }) { ) } -export function WholesaleSidebar() { - const { user, logout } = useAuth() +export function WholesaleSidebar({ user: propUser }: { user?: any } = {}) { + const { user: hookUser, logout } = useAuth() + + // Приоритет: переданный user через props, затем из хука + const user = propUser || hookUser const router = useRouter() const pathname = usePathname() const { isCollapsed, toggleSidebar } = useSidebar() diff --git a/src/components/dashboard/sidebar/index.tsx b/src/components/dashboard/sidebar/index.tsx index c458e2b..ad82c33 100644 --- a/src/components/dashboard/sidebar/index.tsx +++ b/src/components/dashboard/sidebar/index.tsx @@ -13,8 +13,32 @@ declare global { } } -export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean } = {}) { - const { user } = useAuth() +export function Sidebar({ + isRootInstance = false, + user: propUser +}: { + isRootInstance?: boolean, + user?: any +} = {}) { + const { user: hookUser } = useAuth() + + // Приоритет: переданный user через props, затем из хука + const user = propUser || hookUser + + console.warn('Sidebar render:', { + isRootInstance, + hasUser: !!user, + hasPropUser: !!propUser, + hasHookUser: !!hookUser, + userPhone: user?.phone, + organizationType: user?.organization?.type, + willRender: !!user?.organization?.type + }) + + // Диагностика почему user пустой + if (!user) { + console.warn('🔴 Sidebar: NO USER DATA', { propUser, hookUser }) + } // Если уже есть корневой сайдбар и это не корневой экземпляр — не рендерим дубликат if ( @@ -22,6 +46,7 @@ export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean } !isRootInstance && window.__SIDEBAR_ROOT_MOUNTED__ ) { + console.warn('Sidebar: duplicate instance, not rendering') return null } @@ -31,22 +56,23 @@ export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean } } if (!user?.organization?.type) { + console.warn('Sidebar: no user or organization type, not rendering') return null } // Роутинг по типам организаций switch (user.organization.type) { case 'LOGIST': - return + return case 'SELLER': - return + return case 'FULFILLMENT': - return + return case 'WHOLESALE': - return + return default: return null diff --git a/src/components/dashboard/user-settings.tsx b/src/components/dashboard/user-settings.tsx index 6c81f87..4d07c00 100644 --- a/src/components/dashboard/user-settings.tsx +++ b/src/components/dashboard/user-settings.tsx @@ -31,7 +31,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations' +import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN, ADD_MARKETPLACE_API_KEY } from '@/graphql/mutations' import { GET_ME } from '@/graphql/queries' import { useAuth } from '@/hooks/useAuth' import { useSidebar } from '@/hooks/useSidebar' @@ -687,13 +687,59 @@ export function UserSettings() { if (result.data?.updateUserProfile?.success) { setSaveMessage({ type: 'success', - text: 'Профиль успешно сохранен! Обновляем страницу...', + text: 'Профиль успешно сохранен! Сохраняем API ключи...', }) + // Сохраняем API ключи маркетплейсов + try { + 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, + }, + }, + }) + } + + // Обновляем кэш с новыми API ключами + await apolloClient.refetchQueries({ + include: [GET_ME], + }) + + setSaveMessage({ + type: 'success', + text: 'Профиль и API ключи успешно сохранены! Обновляем страницу...', + }) + } catch (apiError) { + console.error('Error saving API keys:', apiError) + setSaveMessage({ + type: 'success', + text: 'Профиль сохранен, но возникла ошибка с API ключами. Обновляем страницу...', + }) + } + // Простое обновление страницы после успешного сохранения setTimeout(() => { window.location.reload() - }, 1000) + }, 1500) } else { setSaveMessage({ type: 'error', @@ -1497,6 +1543,8 @@ export function UserSettings() { placeholder="Введите API ключ Wildberries" readOnly={!isEditing} className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + autoComplete="off" + spellCheck="false" /> {(user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES') || (formData.wildberriesApiKey && isEditing)) && ( @@ -1521,6 +1569,8 @@ export function UserSettings() { placeholder="Введите API ключ Ozon" readOnly={!isEditing} className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + autoComplete="off" + spellCheck="false" /> {(user?.organization?.apiKeys?.find((key) => key.marketplace === 'OZON') || (formData.ozonApiKey && isEditing)) && ( diff --git a/src/components/dashboard/user-settings.tsx.backup b/src/components/dashboard/user-settings.tsx.backup new file mode 100644 index 0000000..f8febf8 --- /dev/null +++ b/src/components/dashboard/user-settings.tsx.backup @@ -0,0 +1,1579 @@ +'use client' + +import { useMutation } from '@apollo/client' +import { + User, + Building2, + Phone, + Mail, + MapPin, + CreditCard, + Key, + Edit3, + CheckCircle, + AlertTriangle, + MessageCircle, + Save, + RefreshCw, + Calendar, + Settings, + Camera, +} from 'lucide-react' +import Image from 'next/image' +import { useState, useEffect, useRef } from 'react' + +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations' +import { GET_ME } from '@/graphql/queries' +import { useAuth } from '@/hooks/useAuth' +import { useSidebar } from '@/hooks/useSidebar' +import { apolloClient } from '@/lib/apollo-client' +import { formatPhone } from '@/lib/utils' +import S3Service from '@/services/s3-service' + +import { Sidebar } from './sidebar' + +export function UserSettings() { + const { getSidebarMargin } = useSidebar() + const { user, updateUser } = useAuth() + const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE) + const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN) + const [isEditing, setIsEditing] = useState(false) + const [saveMessage, setSaveMessage] = useState<{ + type: 'success' | 'error' + text: string + } | null>(null) + const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) + const [localAvatarUrl, setLocalAvatarUrl] = useState(null) + const phoneInputRef = useRef(null) + const whatsappInputRef = useRef(null) + + // Инициализируем данные из пользователя и организации + const [formData, setFormData] = useState({ + // Контактные данные организации + orgPhone: '', // телефон организации, не пользователя + managerName: '', + telegram: '', + whatsapp: '', + email: '', + + // Организация - данные могут быть заполнены из DaData + orgName: '', + address: '', + + // Юридические данные - могут быть заполнены из DaData + fullName: '', + inn: '', + ogrn: '', + registrationPlace: '', + + // Финансовые данные - требуют ручного заполнения + bankName: '', + bik: '', + accountNumber: '', + corrAccount: '', + + // API ключи маркетплейсов + wildberriesApiKey: '', + ozonApiKey: '', + + // Рынок для поставщиков + market: '', + }) + + // Загружаем данные организации при монтировании компонента + useEffect(() => { + if (user?.organization) { + const org = user.organization + + // Извлекаем первый телефон из phones JSON + let orgPhone = '' + if (org.phones && Array.isArray(org.phones) && org.phones.length > 0) { + orgPhone = org.phones[0].value || org.phones[0] || '' + } else if (org.phones && typeof org.phones === 'object') { + const phoneValues = Object.values(org.phones) + if (phoneValues.length > 0) { + orgPhone = String(phoneValues[0]) + } + } + + // Извлекаем email из emails JSON + let email = '' + if (org.emails && Array.isArray(org.emails) && org.emails.length > 0) { + email = org.emails[0].value || org.emails[0] || '' + } else if (org.emails && typeof org.emails === 'object') { + const emailValues = Object.values(org.emails) + if (emailValues.length > 0) { + email = String(emailValues[0]) + } + } + + // Извлекаем дополнительные данные из managementPost (JSON) + let customContacts: { + managerName?: string + telegram?: string + whatsapp?: string + bankDetails?: { + bankName?: string + bik?: string + accountNumber?: string + corrAccount?: string + } + } = {} + try { + if (org.managementPost && typeof org.managementPost === 'string') { + // Проверяем, что строка начинается с { или [, иначе это не JSON + if (org.managementPost.trim().startsWith('{') || org.managementPost.trim().startsWith('[')) { + customContacts = JSON.parse(org.managementPost) + } + } + } catch { + // Игнорируем ошибки парсинга + } + + setFormData({ + orgPhone: orgPhone || '+7', + managerName: user?.managerName || '', + telegram: customContacts?.telegram || '', + whatsapp: customContacts?.whatsapp || '', + email: email, + orgName: org.name || '', + address: org.address || '', + fullName: org.fullName || '', + inn: org.inn || '', + ogrn: org.ogrn || '', + registrationPlace: org.address || '', + bankName: customContacts?.bankDetails?.bankName || '', + bik: customContacts?.bankDetails?.bik || '', + accountNumber: customContacts?.bankDetails?.accountNumber || '', + corrAccount: customContacts?.bankDetails?.corrAccount || '', + wildberriesApiKey: '', + ozonApiKey: '', + market: (org as any).market || 'none', + }) + } + }, [user]) + + const getInitials = () => { + const orgName = user?.organization?.name || user?.organization?.fullName + if (orgName) { + return orgName.charAt(0).toUpperCase() + } + return user?.phone ? user.phone.slice(-2).toUpperCase() : 'О' + } + + const getCabinetTypeName = () => { + if (!user?.organization?.type) return 'Не указан' + + switch (user.organization.type) { + case 'FULFILLMENT': + return 'Фулфилмент' + case 'SELLER': + return 'Селлер' + case 'LOGIST': + return 'Логистика' + case 'WHOLESALE': + return 'Поставщик' + default: + return 'Не указан' + } + } + + // Обновленная функция для проверки заполненности профиля + const checkProfileCompleteness = () => { + // Базовые поля (обязательные для всех) + const baseFields = [ + { + field: 'orgPhone', + label: 'Телефон организации', + value: formData.orgPhone, + }, + { + field: 'managerName', + label: 'Имя управляющего', + value: formData.managerName, + }, + { field: 'email', label: 'Email', value: formData.email }, + ] + + // Дополнительные поля в зависимости от типа кабинета + const additionalFields = [] + if ( + user?.organization?.type === 'FULFILLMENT' || + user?.organization?.type === 'LOGIST' || + user?.organization?.type === 'WHOLESALE' || + user?.organization?.type === 'SELLER' + ) { + // Финансовые данные - всегда обязательны для всех типов кабинетов + additionalFields.push( + { + field: 'bankName', + label: 'Название банка', + value: formData.bankName, + }, + { field: 'bik', label: 'БИК', value: formData.bik }, + { + field: 'accountNumber', + label: 'Расчетный счет', + value: formData.accountNumber, + }, + { + field: 'corrAccount', + label: 'Корр. счет', + value: formData.corrAccount, + }, + ) + } + + const allRequiredFields = [...baseFields, ...additionalFields] + const filledRequiredFields = allRequiredFields.filter((field) => field.value && field.value.trim() !== '').length + + // Подсчитываем бонусные баллы за автоматически заполненные поля + let autoFilledFields = 0 + let totalAutoFields = 0 + + // Номер телефона пользователя для авторизации (не считаем в процентах заполненности) + // Телефон организации учитывается отдельно как обычное поле + + // Данные организации из DaData (если есть ИНН) + if (formData.inn || user?.organization?.inn) { + totalAutoFields += 5 // ИНН + название + адрес + полное название + ОГРН + + if (formData.inn || user?.organization?.inn) autoFilledFields += 1 // ИНН + if (formData.orgName || user?.organization?.name) autoFilledFields += 1 // Название + if (formData.address || user?.organization?.address) autoFilledFields += 1 // Адрес + if (formData.fullName || user?.organization?.fullName) autoFilledFields += 1 // Полное название + if (formData.ogrn || user?.organization?.ogrn) autoFilledFields += 1 // ОГРН + } + + // Место регистрации + if (formData.registrationPlace || user?.organization?.registrationDate) { + autoFilledFields += 1 + totalAutoFields += 1 + } + + const totalPossibleFields = allRequiredFields.length + totalAutoFields + const totalFilledFields = filledRequiredFields + autoFilledFields + + const percentage = totalPossibleFields > 0 ? Math.round((totalFilledFields / totalPossibleFields) * 100) : 0 + const missingFields = allRequiredFields + .filter((field) => !field.value || field.value.trim() === '') + .map((field) => field.label) + + return { percentage, missingFields } + } + + const profileStatus = checkProfileCompleteness() + const isIncomplete = profileStatus.percentage < 100 + + const handleAvatarUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file || !user?.id) return + + setIsUploadingAvatar(true) + setSaveMessage(null) + + try { + const avatarUrl = await S3Service.uploadAvatar(file, user.id) + + // Сразу обновляем локальное состояние для мгновенного отображения + setLocalAvatarUrl(avatarUrl) + + // Обновляем аватар пользователя через GraphQL + const result = await updateUserProfile({ + variables: { + input: { + avatar: avatarUrl, + }, + }, + update: (cache, { data }: { data?: any }) => { + if (data?.updateUserProfile?.success) { + // Обновляем кеш Apollo Client + try { + const existingData: any = cache.readQuery({ query: GET_ME }) + if (existingData?.me) { + cache.writeQuery({ + query: GET_ME, + data: { + me: { + ...existingData.me, + avatar: avatarUrl, + }, + }, + }) + } + } catch { + // Игнорируем ошибки обновления кеша + } + } + }, + }) + + if (result.data?.updateUserProfile?.success) { + setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен!' }) + + // Обновляем локальное состояние в useAuth для мгновенного отображения в сайдбаре + updateUser({ avatar: avatarUrl }) + + // Принудительно обновляем Apollo Client кеш + await apolloClient.refetchQueries({ + include: [GET_ME], + }) + + // Очищаем input файла + if (event.target) { + event.target.value = '' + } + + // Очищаем сообщение через 3 секунды + setTimeout(() => { + setSaveMessage(null) + }, 3000) + } else { + throw new Error(result.data?.updateUserProfile?.message || 'Failed to update avatar') + } + } catch (error) { + console.error('Error uploading avatar:', error) + // Сбрасываем локальное состояние при ошибке + setLocalAvatarUrl(null) + const errorMessage = error instanceof Error ? error.message : 'Ошибка при загрузке аватара' + setSaveMessage({ type: 'error', text: errorMessage }) + // Очищаем сообщение об ошибке через 5 секунд + setTimeout(() => { + setSaveMessage(null) + }, 5000) + } finally { + setIsUploadingAvatar(false) + } + } + + // Функции для валидации и масок + const validateEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) + } + + const formatPhoneInput = (value: string, isOptional: boolean = false) => { + // Убираем все нецифровые символы + const digitsOnly = value.replace(/\D/g, '') + + // Если строка пустая + if (!digitsOnly) { + // Для необязательных полей возвращаем пустую строку + if (isOptional) return '' + // Для обязательных полей возвращаем +7 + return '+7' + } + + // Если пользователь ввел первую цифру не 7, добавляем 7 перед ней + let cleaned = digitsOnly + if (!cleaned.startsWith('7')) { + cleaned = '7' + cleaned + } + + // Ограничиваем до 11 цифр (7 + 10 цифр номера) + cleaned = cleaned.slice(0, 11) + + // Форматируем в зависимости от длины + if (cleaned.length <= 1) return isOptional && cleaned === '7' ? '' : '+7' + if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}` + if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}` + if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}` + if (cleaned.length <= 11) + return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9)}` + + return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}` + } + + const handlePhoneInputChange = ( + field: string, + value: string, + inputRef: React.RefObject, + isOptional: boolean = false, + ) => { + const currentInput = inputRef?.current + const currentCursorPosition = currentInput?.selectionStart || 0 + const currentValue = (formData[field as keyof typeof formData] as string) || '' + + // Для необязательных полей разрешаем пустое значение + if (isOptional && value.length < 2) { + const formatted = formatPhoneInput(value, true) + setFormData((prev) => ({ ...prev, [field]: formatted })) + return + } + + // Для обязательных полей если пользователь пытается удалить +7, предотвращаем это + if (!isOptional && value.length < 2) { + value = '+7' + } + + const formatted = formatPhoneInput(value, isOptional) + setFormData((prev) => ({ ...prev, [field]: formatted })) + + // Вычисляем новую позицию курсора + if (currentInput) { + setTimeout(() => { + let newCursorPosition = currentCursorPosition + + // Если длина увеличилась (добавили цифру), передвигаем курсор + if (formatted.length > currentValue.length) { + newCursorPosition = currentCursorPosition + (formatted.length - currentValue.length) + } + // Если длина уменьшилась (удалили цифру), оставляем курсор на месте или сдвигаем немного + else if (formatted.length < currentValue.length) { + newCursorPosition = Math.min(currentCursorPosition, formatted.length) + } + + // Не позволяем курсору находиться перед +7 + newCursorPosition = Math.max(newCursorPosition, 2) + + // Ограничиваем курсор длиной строки + newCursorPosition = Math.min(newCursorPosition, formatted.length) + + currentInput.setSelectionRange(newCursorPosition, newCursorPosition) + }, 0) + } + } + + const formatTelegram = (value: string) => { + // Убираем все символы кроме букв, цифр, _ и @ + let cleaned = value.replace(/[^a-zA-Z0-9_@]/g, '') + + // Убираем лишние символы @ + cleaned = cleaned.replace(/@+/g, '@') + + // Если есть символы после удаления @ и строка не начинается с @, добавляем @ + if (cleaned && !cleaned.startsWith('@')) { + cleaned = '@' + cleaned + } + + // Ограничиваем длину (максимум 32 символа для Telegram) + if (cleaned.length > 33) { + cleaned = cleaned.substring(0, 33) + } + + return cleaned + } + + const validateName = (name: string) => { + return /^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(name) && name.trim().length >= 2 + } + + const handleInputChange = (field: string, value: string) => { + let processedValue = value + + // Применяем маски и валидации + switch (field) { + case 'orgPhone': + case 'whatsapp': + processedValue = formatPhoneInput(value) + break + case 'telegram': + processedValue = formatTelegram(value) + break + case 'email': + // Для email не применяем маску, только валидацию при потере фокуса + break + case 'managerName': + // Разрешаем только буквы, пробелы и дефисы + processedValue = value.replace(/[^а-яёА-ЯЁa-zA-Z\s-]/g, '') + break + } + + setFormData((prev) => ({ ...prev, [field]: processedValue })) + } + + // Функции для проверки ошибок + const getFieldError = (field: string, value: string) => { + if (!isEditing || !value.trim()) return null + + switch (field) { + case 'email': + return !validateEmail(value) ? 'Неверный формат email' : null + case 'managerName': + return !validateName(value) ? 'Только буквы, пробелы и дефисы' : null + case 'orgPhone': + case 'whatsapp': + const cleaned = value.replace(/\D/g, '') + return cleaned.length !== 11 ? 'Неверный формат телефона' : null + case 'telegram': + // Проверяем что после @ есть минимум 5 символов + const usernameLength = value.startsWith('@') ? value.length - 1 : value.length + return usernameLength < 5 ? 'Минимум 5 символов после @' : null + case 'inn': + // Игнорируем автоматически сгенерированные ИНН селлеров + if (value.startsWith('SELLER_')) { + return null + } + const innCleaned = value.replace(/\D/g, '') + if (innCleaned.length !== 10 && innCleaned.length !== 12) { + return 'ИНН должен содержать 10 или 12 цифр' + } + return null + case 'bankName': + return value.trim().length < 3 ? 'Минимум 3 символа' : null + case 'bik': + const bikCleaned = value.replace(/\D/g, '') + return bikCleaned.length !== 9 ? 'БИК должен содержать 9 цифр' : null + case 'accountNumber': + const accountCleaned = value.replace(/\D/g, '') + return accountCleaned.length !== 20 ? 'Расчетный счет должен содержать 20 цифр' : null + case 'corrAccount': + const corrCleaned = value.replace(/\D/g, '') + return corrCleaned.length !== 20 ? 'Корр. счет должен содержать 20 цифр' : null + default: + return null + } + } + + // Проверка наличия изменений в форме + const hasFormChanges = () => { + if (!user?.organization) return false + + const org = user.organization + + // Извлекаем текущий телефон из organization.phones + let currentOrgPhone = '+7' + if (org.phones && Array.isArray(org.phones) && org.phones.length > 0) { + currentOrgPhone = org.phones[0].value || org.phones[0] || '+7' + } + + // Извлекаем текущий email из organization.emails + let currentEmail = '' + if (org.emails && Array.isArray(org.emails) && org.emails.length > 0) { + currentEmail = org.emails[0].value || org.emails[0] || '' + } + + // Извлекаем дополнительные данные из managementPost + let customContacts: any = {} + try { + if (org.managementPost && typeof org.managementPost === 'string') { + // Проверяем, что строка начинается с { или [, иначе это не JSON + if (org.managementPost.trim().startsWith('{') || org.managementPost.trim().startsWith('[')) { + customContacts = JSON.parse(org.managementPost) + } + } + } catch { + // ignore parse errors + } + + // Нормализуем значения для сравнения + const normalizeValue = (value: string | null | undefined) => value || '' + const normalizeMarketValue = (value: string | null | undefined) => value || 'none' + + // Проверяем изменения в полях + const changes = [ + normalizeValue(formData.orgPhone) !== normalizeValue(currentOrgPhone), + normalizeValue(formData.managerName) !== normalizeValue(user?.managerName), + normalizeValue(formData.telegram) !== normalizeValue(customContacts?.telegram), + normalizeValue(formData.whatsapp) !== normalizeValue(customContacts?.whatsapp), + normalizeValue(formData.email) !== normalizeValue(currentEmail), + normalizeMarketValue(formData.market) !== normalizeMarketValue((org as any).market), + normalizeValue(formData.bankName) !== normalizeValue(customContacts?.bankDetails?.bankName), + normalizeValue(formData.bik) !== normalizeValue(customContacts?.bankDetails?.bik), + normalizeValue(formData.accountNumber) !== normalizeValue(customContacts?.bankDetails?.accountNumber), + normalizeValue(formData.corrAccount) !== normalizeValue(customContacts?.bankDetails?.corrAccount), + ] + + const hasChanges = changes.some((changed) => changed) + return hasChanges + } + + // Проверка наличия ошибок валидации + const hasValidationErrors = () => { + const fields = [ + 'orgPhone', + 'managerName', + 'telegram', + 'whatsapp', + 'email', + 'inn', + 'bankName', + 'bik', + 'accountNumber', + 'corrAccount', + ] + + // Проверяем ошибки валидации только в заполненных полях + const hasErrors = fields.some((field) => { + const value = formData[field as keyof typeof formData] + // Проверяем ошибки только для заполненных полей + if (!value || !value.trim()) return false + + const error = getFieldError(field, value) + return error !== null + }) + + // Убираем проверку обязательных полей - пользователь может заполнять постепенно + return hasErrors + } + + const handleSave = async () => { + // Сброс предыдущих сообщений + setSaveMessage(null) + + try { + // Проверяем, изменился ли ИНН и нужно ли обновить данные организации + const currentInn = formData.inn || user?.organization?.inn || '' + const originalInn = user?.organization?.inn || '' + const innCleaned = currentInn.replace(/\D/g, '') + const originalInnCleaned = originalInn.replace(/\D/g, '') + + // Если ИНН изменился и валиден, сначала обновляем данные организации + if (innCleaned !== originalInnCleaned && (innCleaned.length === 10 || innCleaned.length === 12)) { + setSaveMessage({ + type: 'success', + text: 'Обновляем данные организации...', + }) + + const orgResult = await updateOrganizationByInn({ + variables: { inn: innCleaned }, + }) + + if (!orgResult.data?.updateOrganizationByInn?.success) { + setSaveMessage({ + type: 'error', + text: orgResult.data?.updateOrganizationByInn?.message || 'Ошибка при обновлении данных организации', + }) + return + } + + setSaveMessage({ + type: 'success', + text: 'Данные организации обновлены. Сохраняем профиль...', + }) + } + + // Подготавливаем только заполненные поля для отправки + const inputData: { + orgPhone?: string + managerName?: string + telegram?: string + whatsapp?: string + email?: string + bankName?: string + bik?: string + accountNumber?: string + corrAccount?: string + market?: string + } = {} + + // orgName больше не редактируется - устанавливается только при регистрации + if (formData.orgPhone?.trim()) inputData.orgPhone = formData.orgPhone.trim() + if (formData.managerName?.trim()) inputData.managerName = formData.managerName.trim() + if (formData.telegram?.trim()) inputData.telegram = formData.telegram.trim() + if (formData.whatsapp?.trim()) inputData.whatsapp = formData.whatsapp.trim() + if (formData.email?.trim()) inputData.email = formData.email.trim() + if (formData.bankName?.trim()) inputData.bankName = formData.bankName.trim() + if (formData.bik?.trim()) inputData.bik = formData.bik.trim() + if (formData.accountNumber?.trim()) inputData.accountNumber = formData.accountNumber.trim() + if (formData.corrAccount?.trim()) inputData.corrAccount = formData.corrAccount.trim() + if (formData.market) inputData.market = formData.market + + const result = await updateUserProfile({ + variables: { + input: inputData, + }, + }) + + if (result.data?.updateUserProfile?.success) { + setSaveMessage({ + type: 'success', + text: 'Профиль успешно сохранен! Обновляем страницу...', + }) + + // Простое обновление страницы после успешного сохранения + setTimeout(() => { + window.location.reload() + }, 1000) + } else { + setSaveMessage({ + type: 'error', + text: result.data?.updateUserProfile?.message || 'Ошибка при сохранении профиля', + }) + } + } catch (error) { + console.error('Error saving profile:', error) + setSaveMessage({ type: 'error', text: 'Ошибка при сохранении профиля' }) + } + } + + const formatDate = (dateString?: string) => { + if (!dateString) return '' + try { + let date: Date + + // Проверяем, является ли строка числом (Unix timestamp) + if (/^\d+$/.test(dateString)) { + // Если это Unix timestamp в миллисекундах + const timestamp = parseInt(dateString, 10) + date = new Date(timestamp) + } else { + // Обычная строка даты + date = new Date(dateString) + } + + if (isNaN(date.getTime())) { + return 'Неверная дата' + } + + return date.toLocaleDateString('ru-RU', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + } catch { + return 'Ошибка даты' + } + } + + return ( +
+ +
+
+ {/* Сообщения о сохранении */} + {saveMessage && ( + + + {saveMessage.text} + + + )} + + {/* Основной контент с вкладками - заполняет оставшееся пространство */} +
+ + + + + Профиль + + + + Организация + + {(user?.organization?.type === 'FULFILLMENT' || + user?.organization?.type === 'LOGIST' || + user?.organization?.type === 'WHOLESALE' || + user?.organization?.type === 'SELLER') && ( + + + Финансы + + )} + {user?.organization?.type === 'SELLER' && ( + + + API + + )} + {user?.organization?.type !== 'SELLER' && ( + + + Инструменты + + )} + + + {/* Профиль пользователя */} + + + {/* Заголовок вкладки с прогрессом и кнопками */} +
+
+ +
+

Профиль пользователя

+

Личная информация и контактные данные

+
+
+
+ {/* Компактный индикатор прогресса */} +
+
+ {profileStatus.percentage}% +
+
+ {isIncomplete ? ( + <>Заполнено {profileStatus.percentage}% профиля + ) : ( + <>Профиль полностью заполнен + )} +
+
+ + {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+
+
+ + {localAvatarUrl || user?.avatar ? ( + Аватар + ) : ( + {getInitials()} + )} + +
+ + +
+
+
+

+ {user?.organization?.name || user?.organization?.fullName || 'Пользователь'} +

+ + {getCabinetTypeName()} + +

+ Авторизован по номеру: {formatPhone(user?.phone || '')} +

+ {user?.createdAt && ( +

+ + Дата регистрации: {formatDate(user.createdAt)} +

+ )} +
+ +
+ +
+
+
+ + handlePhoneInputChange('orgPhone', e.target.value, phoneInputRef)} + onKeyDown={(e) => { + // Предотвращаем удаление +7 + if ( + (e.key === 'Backspace' || e.key === 'Delete') && + (phoneInputRef.current?.selectionStart || 0) <= 2 + ) { + e.preventDefault() + } + }} + placeholder="+7 (999) 999-99-99" + readOnly={!isEditing} + className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ + getFieldError('orgPhone', formData.orgPhone) ? 'border-red-400' : '' + }`} + /> + {getFieldError('orgPhone', formData.orgPhone) && ( +

+ + {getFieldError('orgPhone', formData.orgPhone)} +

+ )} +
+ +
+ + handleInputChange('managerName', e.target.value)} + placeholder="Иван Иванов" + readOnly={!isEditing} + className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ + getFieldError('managerName', formData.managerName) ? 'border-red-400' : '' + }`} + /> + {getFieldError('managerName', formData.managerName) && ( +

+ + {getFieldError('managerName', formData.managerName)} +

+ )} +
+
+ +
+
+ + handleInputChange('telegram', e.target.value)} + placeholder="@username" + readOnly={!isEditing} + className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ + getFieldError('telegram', formData.telegram) ? 'border-red-400' : '' + }`} + /> + {getFieldError('telegram', formData.telegram) && ( +

+ + {getFieldError('telegram', formData.telegram)} +

+ )} +
+ +
+ + handlePhoneInputChange('whatsapp', e.target.value, whatsappInputRef, true)} + onKeyDown={(_e) => { + // Для WhatsApp разрешаем полное удаление (поле необязательное) + // Никаких ограничений на удаление + }} + placeholder="Необязательно" + readOnly={!isEditing} + className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ + getFieldError('whatsapp', formData.whatsapp) ? 'border-red-400' : '' + }`} + /> + {getFieldError('whatsapp', formData.whatsapp) && ( +

+ + {getFieldError('whatsapp', formData.whatsapp)} +

+ )} +
+ +
+ + handleInputChange('email', e.target.value)} + placeholder="example@company.com" + readOnly={!isEditing} + className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ + getFieldError('email', formData.email) ? 'border-red-400' : '' + }`} + /> + {getFieldError('email', formData.email) && ( +

+ + {getFieldError('email', formData.email)} +

+ )} +
+
+
+
+
+ + {/* Организация и юридические данные */} + + + {/* Заголовок вкладки с кнопками */} +
+
+ +
+

Данные организации

+

Юридическая информация и реквизиты

+
+
+
+ {(formData.inn || user?.organization?.inn) && ( +
+ + Проверено +
+ )} + + {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ + {/* Общая подпись про реестр */} +
+

+ + При сохранении с измененным ИНН мы автоматически обновляем все остальные данные из федерального + реестра +

+
+ +
+ {/* Названия */} +
+
+ + handleInputChange('orgName', e.target.value)} + placeholder={ + user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации' + } + readOnly={true} + className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + /> + {user?.organization?.type === 'SELLER' ? ( +

+ Название устанавливается при регистрации кабинета и не может быть изменено. +

+ ) : ( +

+ Автоматически заполняется из федерального реестра при указании ИНН. +

+ )} +
+ +
+ + +
+
+ + {/* Адреса */} +
+
+ + handleInputChange('address', e.target.value)} + placeholder="г. Москва, ул. Примерная, д. 1" + readOnly={!isEditing || !!(formData.address || user?.organization?.address)} + className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + /> +
+ +
+ + +
+
+ + {/* ИНН, ОГРН, КПП */} +
+
+ + { + handleInputChange('inn', e.target.value) + }} + placeholder="Введите ИНН организации" + readOnly={!isEditing} + disabled={isUpdatingOrganization} + className={`glass-input text-white placeholder:text-white/40 h-10 ${ + !isEditing ? 'read-only:opacity-70' : '' + } ${ + getFieldError('inn', formData.inn) ? 'border-red-400' : '' + } ${isUpdatingOrganization ? 'opacity-50' : ''}`} + /> + {getFieldError('inn', formData.inn) && ( +

{getFieldError('inn', formData.inn)}

+ )} +
+ +
+ + +
+ +
+ + +
+
+ + {/* Руководитель и статус */} +
+
+ + +

+ {user?.organization?.managementName + ? 'Данные из федерального реестра' + : 'Автоматически заполняется из федерального реестра при указании ИНН'} +

+
+ +
+ + +
+
+ + {/* Дата регистрации */} + {user?.organization?.registrationDate && ( +
+
+ + +
+
+ )} + + {/* Настройка рынка для поставщиков */} + {user?.organization?.type === 'WHOLESALE' && ( +
+
+ + {isEditing ? ( + + ) : ( + + )} +

+ Физический рынок, где работает поставщик. Товары наследуют рынок от организации. +

+
+
+ )} +
+
+
+ + {/* Финансовые данные */} + {(user?.organization?.type === 'FULFILLMENT' || + user?.organization?.type === 'LOGIST' || + user?.organization?.type === 'WHOLESALE' || + user?.organization?.type === 'SELLER') && ( + + + {/* Заголовок вкладки с кнопками */} +
+
+ +
+

Финансовые данные

+

Банковские реквизиты для расчетов

+
+
+
+ {formData.bankName && formData.bik && formData.accountNumber && formData.corrAccount && ( +
+ + Заполнено +
+ )} + + {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ +
+
+ + handleInputChange('bankName', e.target.value)} + placeholder="ПАО Сбербанк" + readOnly={!isEditing} + className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + /> +
+ +
+
+ + handleInputChange('bik', e.target.value)} + placeholder="044525225" + readOnly={!isEditing} + className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + /> +
+ +
+ + handleInputChange('corrAccount', e.target.value)} + placeholder="30101810400000000225" + readOnly={!isEditing} + className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + /> +
+
+ +
+ + handleInputChange('accountNumber', e.target.value)} + placeholder="40702810123456789012" + readOnly={!isEditing} + className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + /> +
+
+
+
+ )} + + {/* API ключи для селлера */} + {user?.organization?.type === 'SELLER' && ( + + + {/* Заголовок вкладки с кнопками */} +
+
+ +
+

API ключи маркетплейсов

+

Интеграция с торговыми площадками

+
+
+
+ {user?.organization?.apiKeys?.length > 0 && ( +
+ + Настроено +
+ )} + + {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ +
+
+ + key.marketplace === 'WILDBERRIES') + ? '••••••••••••••••••••' + : '' + } + onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)} + placeholder="Введите API ключ Wildberries" + readOnly={!isEditing} + className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + autoComplete="off" + spellCheck="false" + /> + {(user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES') || + (formData.wildberriesApiKey && isEditing)) && ( +

+ + {!isEditing ? 'API ключ настроен' : 'Будет сохранен'} +

+ )} +
+ +
+ + key.marketplace === 'OZON') + ? '••••••••••••••••••••' + : '' + } + onChange={(e) => handleInputChange('ozonApiKey', e.target.value)} + placeholder="Введите API ключ Ozon" + readOnly={!isEditing} + className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70" + autoComplete="off" + spellCheck="false" + /> + {(user?.organization?.apiKeys?.find((key) => key.marketplace === 'OZON') || + (formData.ozonApiKey && isEditing)) && ( +

+ + {!isEditing ? 'API ключ настроен' : 'Будет сохранен'} +

+ )} +
+
+
+
+ )} + + {/* Инструменты */} + + + {/* Заголовок вкладки */} +
+
+ +
+

Инструменты

+

Дополнительные возможности для бизнеса

+
+
+
+ +
+
+ +

Инструменты в разработке

+

+ Здесь будут размещены полезные бизнес-инструменты: калькуляторы, аналитика, планировщики и + автоматизация процессов. +

+
+ + Скоро появится + +
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 40fa5e8..10a50cc 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -5,6 +5,8 @@ import { usePathname } from 'next/navigation' import { useEffect, useState } from 'react' import { useAuth } from '@/hooks/useAuth' +import { useQuery } from '@apollo/client' +import { GET_ME } from '@/graphql/queries' // Рендерим сайдбар только на клиенте, отключаем SSR, чтобы избежать гидратационных расхождений const Sidebar = dynamic(() => import('@/components/dashboard/sidebar').then((m) => m.Sidebar), { @@ -15,6 +17,14 @@ export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname() const { isAuthenticated, isLoading } = useAuth() const [mounted, setMounted] = useState(false) + + // Используем GET_ME напрямую чтобы получить актуальные данные + const { data: meData } = useQuery(GET_ME, { + skip: !isAuthenticated, + fetchPolicy: 'cache-first', // Используем кеш Apollo + }) + + const user = meData?.me || null useEffect(() => { setMounted(true) @@ -24,11 +34,40 @@ export function AppShell({ children }: { children: React.ReactNode }) { pathname === '/' || pathname?.startsWith('/login') || pathname?.startsWith('/register') // До маунта всегда скрываем сайдбар, чтобы избежать гидратационного рассинхрона + // ВРЕМЕННО: показываем сайдбар если авторизован, даже без данных пользователя const hideSidebar = !mounted || hideByRoute || isLoading || !isAuthenticated + // Отладочные логи (включены в development) + if (process.env.NODE_ENV === 'development') { + console.warn('AppShell state:', { + mounted, + hideByRoute, + isLoading, + isAuthenticated, + hasUser: !!user, + hasDirectMeData: !!meData?.me, + hideSidebar, + pathname, + userPhone: user?.phone, + organizationType: user?.organization?.type + }) + + // Дополнительная диагностика + if (!hideSidebar) { + console.warn('🟢 AppShell: RENDERING SIDEBAR with user:', user) + } else { + console.warn('🔴 AppShell: NOT RENDERING SIDEBAR because:', { + notMounted: !mounted, + hideByRoute, + isLoading, + notAuthenticated: !isAuthenticated + }) + } + } + return ( <> - {!hideSidebar && } + {!hideSidebar && }
{children}
) diff --git a/src/components/seller-statistics/advertising-tab/index.tsx b/src/components/seller-statistics/advertising-tab/index.tsx index 2a674b2..81c0b74 100644 --- a/src/components/seller-statistics/advertising-tab/index.tsx +++ b/src/components/seller-statistics/advertising-tab/index.tsx @@ -63,25 +63,18 @@ export const AdvertisingTab = memo(function AdvertisingTab({ {/* Блок ошибок */} - {/* Селектор кампаний - пока заглушка */} -
-

- 🚧 Селектор кампаний будет добавлен в следующих компонентах -

-
- - {/* График расходов - пока заглушка */} -
-

- 📊 График расходов будет добавлен в следующих компонентах -

-
- - {/* Таблица данных - пока заглушка */} -
-

- 📋 Таблица статистики будет добавлена в следующих компонентах -

+ {/* Статистика рекламы - в разработке */} +
+
+
+ 📈 +
+

Статистика рекламы

+

+ Раздел рекламной статистики находится в разработке. + Здесь будут отображаться данные о кампаниях, расходах и эффективности рекламы. +

+
{/* Состояние загрузки/пустых данных */} @@ -90,19 +83,21 @@ export const AdvertisingTab = memo(function AdvertisingTab({ hasData={hasData} /> - {/* Отладочная информация */} -
-
Период: {selectedPeriod}
-
Кастомные даты: {useCustomDates ? 'Да' : 'Нет'}
-
Данных: {dailyData.length} дней
-
Фотографий: {productPhotos.size}
-
UI состояние: {JSON.stringify({ - expandedDays: uiState.expandedDays.size, - expandedProducts: uiState.expandedProducts.size, - showWbAds: uiState.showWbAds, - showExternalAds: uiState.showExternalAds, - })}
-
+ {/* Временно скрываем отладочную информацию */} + {process.env.NODE_ENV === 'development' && false && ( +
+
Период: {selectedPeriod}
+
Кастомные даты: {useCustomDates ? 'Да' : 'Нет'}
+
Данных: {dailyData.length} дней
+
Фотографий: {productPhotos.size}
+
UI состояние: {JSON.stringify({ + expandedDays: uiState.expandedDays.size, + expandedProducts: uiState.expandedProducts.size, + showWbAds: uiState.showWbAds, + showExternalAds: uiState.showExternalAds, + })}
+
+ )}
) }) diff --git a/src/components/seller-statistics/seller-statistics-dashboard.tsx b/src/components/seller-statistics/seller-statistics-dashboard.tsx index 5da0aad..b4884ef 100644 --- a/src/components/seller-statistics/seller-statistics-dashboard.tsx +++ b/src/components/seller-statistics/seller-statistics-dashboard.tsx @@ -126,7 +126,7 @@ const SellerStatisticsDashboard = React.memo(() => { return (
- + {/* Sidebar рендерится через AppShell, не дублируем здесь */}
{/* Убираем ограничение по ширине для полного использования экрана */} diff --git a/src/graphql/resolvers/domains/user-management.ts b/src/graphql/resolvers/domains/user-management.ts index 6ea6233..daceb59 100644 --- a/src/graphql/resolvers/domains/user-management.ts +++ b/src/graphql/resolvers/domains/user-management.ts @@ -26,13 +26,17 @@ export const userManagementResolvers: DomainResolvers = { Query: { // Получить текущего пользователя me: async (_: unknown, __: unknown, context: Context) => { + console.warn('🔍 USER-MANAGEMENT ME QUERY:', { hasUser: !!context.user, userId: context.user?.id }) + if (!context.user) { + console.warn('❌ USER-MANAGEMENT ME: No user in context') throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' }, }) } - return await prisma.user.findUnique({ + console.warn('🔍 USER-MANAGEMENT ME: Fetching user from DB...') + const user = await prisma.user.findUnique({ where: { id: context.user.id }, include: { organization: { @@ -42,6 +46,15 @@ export const userManagementResolvers: DomainResolvers = { }, }, }) + + console.warn('✅ USER-MANAGEMENT ME RESULT:', { + found: !!user, + hasOrganization: !!user?.organization, + organizationType: user?.organization?.type, + phone: user?.phone + }) + + return user }, }, diff --git a/src/graphql/resolvers/domains/wildberries.ts b/src/graphql/resolvers/domains/wildberries.ts index e6e6a75..8631247 100644 --- a/src/graphql/resolvers/domains/wildberries.ts +++ b/src/graphql/resolvers/domains/wildberries.ts @@ -166,7 +166,46 @@ export const wildberriesResolvers: DomainResolvers = { } const wbService = new WildberriesService(apiKey.apiKey) - const statistics = await wbService.getStatistics(args.startDate, args.endDate) + console.log('🔑 API Key length:', apiKey.apiKey?.length) + console.log('📅 Date params:', { startDate: args.startDate, endDate: args.endDate, period: args.period }) + + // Если нет конкретных дат, генерируем их на основе периода + let startDate = args.startDate + let endDate = args.endDate + + if (!startDate || !endDate) { + const now = new Date() + const today = now.toISOString().split('T')[0] // YYYY-MM-DD + + switch (args.period) { + case 'week': + const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + startDate = weekAgo.toISOString().split('T')[0] + endDate = today + break + case 'month': + const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + startDate = monthAgo.toISOString().split('T')[0] + endDate = today + break + default: + // По умолчанию неделя + const defaultWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + startDate = defaultWeekAgo.toISOString().split('T')[0] + endDate = today + } + } + + console.log('📅 Calculated dates:', { startDate, endDate }) + + const statistics = await wbService.getStatistics(startDate, endDate) + + console.log('📊 Statistics result:', { + type: typeof statistics, + isArray: Array.isArray(statistics), + length: statistics?.length, + firstItem: statistics?.[0] ? Object.keys(statistics[0]) : null + }) console.log('✅ GET_WILDBERRIES_STATISTICS DOMAIN SUCCESS') diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 013d1db..cc0b48b 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,5 +1,5 @@ import { useMutation } from '@apollo/client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { SEND_SMS_CODE, @@ -172,7 +172,7 @@ export const useAuth = (): UseAuthReturn => { const [registerOrganizationMutation] = useMutation(REGISTER_ORGANIZATION) // 🚀 Новая мутация // Проверка авторизации при инициализации - const checkAuth = async () => { + const checkAuth = useCallback(async () => { if (isCheckingAuth) { console.warn('useAuth - checkAuth already in progress, skipping') return @@ -198,13 +198,17 @@ export const useAuth = (): UseAuthReturn => { fetchPolicy: 'network-only', // Всегда делаем свежий запрос }) - console.warn('useAuth - GET_ME response:', !!data?.me) + console.warn('useAuth - GET_ME response:', { data, me: data?.me }) if (data?.me) { + console.warn('useAuth - Setting user:', data.me) setUser(data.me) setIsAuthenticated(true) setUserData(data.me) - console.warn('useAuth - User authenticated:', data.me.phone) + + // Форсируем обновление состояния + console.warn('🔄 useAuth - State updated, user is now:', data.me) } else { + console.warn('useAuth - No user data in response') setIsAuthenticated(false) setUser(null) } @@ -223,29 +227,18 @@ export const useAuth = (): UseAuthReturn => { } finally { setIsCheckingAuth(false) } - } + }, []) - // Проверяем авторизацию при загрузке компонента только если нет данных пользователя + // Убираем автоматический checkAuth из useEffect - он будет вызываться только из AuthGuard useEffect(() => { const token = getAuthToken() - console.warn( - 'useAuth - useEffect init, token exists:', - !!token, - 'user exists:', - !!user, - 'isChecking:', - isCheckingAuth, - ) - if (token && !user && !isCheckingAuth) { - console.warn('useAuth - Running checkAuth because token exists but no user data') - checkAuth() - } else if (!token) { - console.warn('useAuth - No token, setting unauthenticated state') + // Только устанавливаем состояние без токена + if (!token) { setIsAuthenticated(false) setUser(null) } - }, []) // eslint-disable-line react-hooks/exhaustive-deps + }, []) // Только один раз при монтировании const sendSmsCode = async (phone: string) => { try { diff --git a/src/services/wildberries-service.ts b/src/services/wildberries-service.ts index f1c972c..430c798 100644 --- a/src/services/wildberries-service.ts +++ b/src/services/wildberries-service.ts @@ -395,6 +395,13 @@ class WildberriesService { ? { Authorization: `Bearer ${this.apiKey}` } // Marketplace и Content API используют Bearer : { Authorization: this.apiKey } // Statistics и Advert API используют прямой токен + console.log('🌐 WB API REQUEST:', { + url, + method: options.method || 'GET', + hasAuth: !!authHeader.Authorization, + authType: url.includes('marketplace-api') || url.includes('content-api') ? 'Bearer' : 'Direct' + }) + // Добавляем AbortController для timeout const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 секунд timeout @@ -413,6 +420,12 @@ class WildberriesService { clearTimeout(timeoutId) // Очищаем timeout при успешном ответе if (!response.ok) { + console.error('🚨 WB API REQUEST FAILED:', { + url, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(Object.entries(authHeader)) + }) throw new Error(`WB API Error: ${response.status} ${response.statusText}`) }