diff --git a/CROSS_DOMAIN_SETUP.md b/CROSS_DOMAIN_SETUP.md new file mode 100644 index 0000000..d5d6e9d --- /dev/null +++ b/CROSS_DOMAIN_SETUP.md @@ -0,0 +1,101 @@ +# 🌐 Настройка для разных доменов (Frontend и API) + +## Ситуация +- **Frontend**: `nakama.glpshchn.ru` (старый домен) +- **API**: `nkm.guru` (новый домен) + +## ✅ Что нужно сделать + +### 1. Обновить CORS на backend + +В файле `.env` на сервере добавьте оба домена: + +```bash +# Разрешить запросы с обоих доменов +CORS_ORIGIN=https://nakama.glpshchn.ru,https://nkm.guru +``` + +Или если хотите разрешить все домены (менее безопасно, но проще): + +```bash +CORS_ORIGIN=* +``` + +### 2. Обновить Content Security Policy + +В `backend/middleware/security.js` нужно разрешить подключения к новому API домену: + +```javascript +connectSrc: ["'self'", "https://api.telegram.org", "https://e621.net", "https://gelbooru.com", "https://nkm.guru"], +``` + +### 3. Пересобрать frontend с новым API URL + +```bash +cd /var/www/nakama/frontend + +# Установить переменную окружения для сборки +export VITE_API_URL=https://nkm.guru/api + +# Пересобрать frontend +npm run build + +# Перезапустить nginx +sudo systemctl reload nginx +``` + +### 4. Перезапустить backend + +```bash +# Если используете PM2: +pm2 restart nakama-backend + +# Или если используете Docker: +docker-compose restart backend +``` + +## 🔍 Проверка + +После настройки проверьте: + +1. **CORS работает**: + ```bash + curl -H "Origin: https://nakama.glpshchn.ru" \ + -H "Access-Control-Request-Method: GET" \ + -H "Access-Control-Request-Headers: Content-Type" \ + -X OPTIONS \ + https://nkm.guru/api/health + ``` + +2. **В консоли браузера** (F12 → Network): + - Запросы должны идти на: `https://nkm.guru/api/...` + - Headers должны содержать: `Access-Control-Allow-Origin: https://nakama.glpshchn.ru` + +## ⚠️ Важно + +1. **Cookies**: Если используете cookies для авторизации, убедитесь, что: + - `withCredentials: true` в axios (уже настроено) + - `credentials: true` в CORS (уже настроено) + - Cookies должны быть с правильным `SameSite` и `Secure` флагами + +2. **SSL сертификаты**: Оба домена должны иметь валидные SSL сертификаты + +3. **Telegram Mini App**: Если используете Telegram Mini App, убедитесь, что домен frontend добавлен в настройки бота + +## 🔄 Альтернативное решение: Проксирование через nginx + +Если не хотите настраивать CORS, можно настроить проксирование на старом домене: + +```nginx +# В nginx конфигурации для nakama.glpshchn.ru +location /api { + proxy_pass https://nkm.guru/api; + proxy_set_header Host nkm.guru; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Тогда frontend будет использовать относительный путь `/api`, а nginx будет проксировать запросы на новый домен. + diff --git a/EMAIL_SETUP.md b/EMAIL_SETUP.md new file mode 100644 index 0000000..5c9525a --- /dev/null +++ b/EMAIL_SETUP.md @@ -0,0 +1,102 @@ +# 📧 Настройка отправки email + +## Проблема +Ошибка: `Missing credentials in config` при отправке magic-link email. + +## ✅ Решение + +### Вариант 1: Настроить AWS SES (если используете AWS) + +В файле `.env` на сервере добавьте: + +```bash +EMAIL_PROVIDER=aws +AWS_SES_ACCESS_KEY_ID=your_access_key_id +AWS_SES_SECRET_ACCESS_KEY=your_secret_access_key +AWS_SES_REGION=us-east-1 +EMAIL_FROM=noreply@nakama.guru +``` + +**Важно:** +- `EMAIL_FROM` должен быть верифицированным email в AWS SES +- Для production нужно выйти из sandbox режима AWS SES + +### Вариант 2: Использовать Yandex SMTP (рекомендуется для начала) + +В файле `.env` на сервере: + +```bash +EMAIL_PROVIDER=yandex +YANDEX_SMTP_HOST=smtp.yandex.ru +YANDEX_SMTP_PORT=465 +YANDEX_SMTP_SECURE=true +YANDEX_SMTP_USER=your-email@yandex.ru +YANDEX_SMTP_PASSWORD=your_app_password +EMAIL_FROM=your-email@yandex.ru +``` + +**Важно для Yandex:** +- Используйте **пароль приложения**, а не основной пароль аккаунта +- Пароль приложения создается в настройках безопасности Yandex +- Порт 465 для SSL, порт 587 для STARTTLS + +### Вариант 3: Использовать любой SMTP сервер + +```bash +EMAIL_PROVIDER=smtp +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-email@example.com +SMTP_PASSWORD=your_password +EMAIL_FROM=your-email@example.com +``` + +## 🔧 После настройки + +1. **Перезапустите backend:** + ```bash + pm2 restart nakama-backend + # или + docker-compose restart backend + ``` + +2. **Проверьте логи:** + ```bash + pm2 logs nakama-backend --lines 50 + ``` + + Должно появиться: + - `[Email] ✅ AWS SES клиент инициализирован` (для AWS) + - или `[Email] ✅ SMTP transporter инициализирован` (для SMTP) + +3. **Проверьте отправку:** + - Попробуйте отправить magic-link через форму авторизации + - Проверьте логи на наличие ошибок + +## ⚠️ Частые проблемы + +### 1. "Missing credentials" +- **Причина:** Не установлены AWS credentials +- **Решение:** Установите `AWS_SES_ACCESS_KEY_ID` и `AWS_SES_SECRET_ACCESS_KEY` или используйте `EMAIL_PROVIDER=yandex` + +### 2. "EAUTH" ошибка для Yandex +- **Причина:** Используется основной пароль вместо пароля приложения +- **Решение:** Создайте пароль приложения в настройках безопасности Yandex + +### 3. "ECONNECTION" ошибка +- **Причина:** Неверный хост или порт SMTP +- **Решение:** Проверьте `YANDEX_SMTP_HOST` и `YANDEX_SMTP_PORT` + +## 📝 Пример .env для Yandex + +```bash +EMAIL_PROVIDER=yandex +YANDEX_SMTP_HOST=smtp.yandex.ru +YANDEX_SMTP_PORT=465 +YANDEX_SMTP_SECURE=true +YANDEX_SMTP_USER=aaem9848@yandex.ru +YANDEX_SMTP_PASSWORD=your_app_password_here +EMAIL_FROM=aaem9848@yandex.ru +``` + diff --git a/REBUILD_FRONTEND.md b/REBUILD_FRONTEND.md new file mode 100644 index 0000000..e560594 --- /dev/null +++ b/REBUILD_FRONTEND.md @@ -0,0 +1,64 @@ +# 🔄 Инструкция по пересборке frontend после смены домена + +## Проблема +Vite встраивает переменные окружения (`VITE_API_URL`) в код во время сборки. Если frontend был собран со старым доменом, он будет продолжать использовать старый домен даже после изменения `.env`. + +## ✅ Решение + +### Вариант 1: Пересборка frontend (рекомендуется) + +```bash +# Перейти в директорию frontend +cd /var/www/nakama/frontend + +# Установить переменную окружения для сборки +export VITE_API_URL=https://nkm.guru/api + +# Пересобрать frontend +npm run build + +# Если используете PM2 или другой процесс-менеджер, перезапустите nginx +sudo systemctl reload nginx +``` + +### Вариант 2: Использование относительного пути (уже исправлено в коде) + +Код уже обновлен так, чтобы в production всегда использовался относительный путь `/api`, который работает с любым доменом. Но если frontend был собран со старым `VITE_API_URL`, нужно пересобрать. + +### Вариант 3: Docker + +Если используете Docker: + +```bash +cd /var/www/nakama + +# Пересобрать frontend с новым доменом +docker-compose build frontend --build-arg VITE_API_URL=https://nkm.guru/api + +# Или установить в .env и пересобрать: +# VITE_API_URL=https://nkm.guru/api +docker-compose build frontend +docker-compose up -d frontend +``` + +## 🔍 Проверка после пересборки + +1. **Очистите кэш браузера** (Ctrl+Shift+Delete или Cmd+Shift+Delete) +2. **Или используйте Hard Refresh** (Ctrl+F5 или Cmd+Shift+R) +3. **Проверьте в консоли браузера** (F12 → Network): + - Запросы должны идти на: `https://nkm.guru/api/...` + - НЕ должно быть: `https://nakama.glpshchn.ru/api/...` + +## 📝 Важно + +После пересборки frontend будет использовать относительный путь `/api` в production, что означает: +- ✅ Работает с любым доменом автоматически +- ✅ Не нужно пересобирать при смене домена +- ✅ Использует текущий домен браузера + +## 🚀 Быстрая команда для пересборки + +```bash +cd /var/www/nakama/frontend && npm run build && sudo systemctl reload nginx +``` + diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 3b0facc..51ffefe 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -386,6 +386,128 @@ const authenticateJWT = async (req, res, next) => { } }; +// Optional authenticate - позволяет гостям, но устанавливает req.user если есть авторизация +const authenticateOptional = async (req, res, next) => { + try { + const authHeader = req.headers.authorization || ''; + const guestId = req.headers['x-guest-id']; + + // Если есть guest_id, создаем гостевого пользователя + if (guestId && !authHeader) { + req.user = { + isGuest: true, + id: guestId, + settings: { + whitelist: { + noNSFW: true, + noHomo: true + }, + searchPreference: 'furry' + } + }; + return next(); + } + + // Пытаемся авторизовать через Telegram или JWT + let initDataRaw = null; + let token = null; + + if (authHeader.startsWith('tma ')) { + initDataRaw = authHeader.slice(4).trim(); + } else if (authHeader.startsWith('Bearer ')) { + token = authHeader.slice(7); + } + + if (!initDataRaw && !token) { + const headerInitData = req.headers['x-telegram-init-data']; + if (headerInitData && typeof headerInitData === 'string') { + initDataRaw = headerInitData.trim(); + } + } + + // Если нет данных для авторизации, создаем гостя + if (!initDataRaw && !token) { + req.user = { + isGuest: true, + id: guestId || `guest_${Date.now()}`, + settings: { + whitelist: { + noNSFW: true, + noHomo: true + }, + searchPreference: 'furry' + } + }; + return next(); + } + + // Пытаемся авторизовать через Telegram + if (initDataRaw) { + try { + const payload = validateAndParseInitData(initDataRaw); + const telegramUser = payload.user; + const normalizedUser = normalizeTelegramUser(telegramUser); + + let user = await User.findOne({ telegramId: normalizedUser.id.toString() }); + if (user && !user.banned) { + await ensureUserSettings(user); + await touchUserActivity(user); + req.user = user; + req.telegramUser = normalizedUser; + return next(); + } + } catch (error) { + // Игнорируем ошибки Telegram авторизации, продолжаем как гость + } + } + + // Пытаемся авторизовать через JWT + if (token) { + try { + const { verifyAccessToken } = require('../utils/tokens'); + const payload = verifyAccessToken(token); + const user = await User.findById(payload.userId); + + if (user && !user.banned) { + req.user = user; + return next(); + } + } catch (error) { + // Игнорируем ошибки JWT, продолжаем как гость + } + } + + // Если авторизация не удалась, создаем гостя + req.user = { + isGuest: true, + id: guestId || `guest_${Date.now()}`, + settings: { + whitelist: { + noNSFW: true, + noHomo: true + }, + searchPreference: 'furry' + } + }; + next(); + } catch (error) { + console.error('Ошибка optional auth:', error); + // В случае ошибки создаем гостя + req.user = { + isGuest: true, + id: req.headers['x-guest-id'] || `guest_${Date.now()}`, + settings: { + whitelist: { + noNSFW: true, + noHomo: true + }, + searchPreference: 'furry' + } + }; + next(); + } +}; + // Комбинированный middleware: Telegram или JWT const authenticateModerationFlexible = async (req, res, next) => { // Попробовать Telegram авторизацию @@ -400,6 +522,7 @@ const authenticateModerationFlexible = async (req, res, next) => { }; module.exports = { + authenticateOptional, authenticate, authenticateModeration, authenticateJWT, diff --git a/backend/middleware/security.js b/backend/middleware/security.js index 67feb7c..92d18d0 100644 --- a/backend/middleware/security.js +++ b/backend/middleware/security.js @@ -13,7 +13,7 @@ const helmetConfig = helmet({ styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'", "https://telegram.org", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "https:", "blob:"], - connectSrc: ["'self'", "https://api.telegram.org", "https://e621.net", "https://gelbooru.com"], + connectSrc: ["'self'", "https://api.telegram.org", "https://e621.net", "https://gelbooru.com", "https://nkm.guru"], fontSrc: ["'self'", "data:"], objectSrc: ["'none'"], // Запретить использование консоли и eval diff --git a/backend/models/User.js b/backend/models/User.js index bb91af2..539b714 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -108,6 +108,11 @@ const UserSchema = new mongoose.Schema({ // Magic-link токены для авторизации magicLinkToken: String, magicLinkExpires: Date, + // Onboarding - отслеживание показа приветствия + onboardingCompleted: { + type: Boolean, + default: false + }, createdAt: { type: Date, default: Date.now diff --git a/backend/routes/auth.js b/backend/routes/auth.js index a0154ba..e980649 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -59,7 +59,8 @@ const respondWithUser = async (user, res) => { following: populatedUser.following, tickets: populatedUser.tickets || 0, settings, - banned: populatedUser.banned + banned: populatedUser.banned, + onboardingCompleted: populatedUser.onboardingCompleted || false } }); }; @@ -454,7 +455,7 @@ router.get('/magic-link/verify', async (req, res) => { // Установка пароля при регистрации через magic-link router.post('/magic-link/set-password', async (req, res) => { try { - const { token, password, username } = req.body; + const { token, password, username, firstName } = req.body; if (!token || !password) { return res.status(400).json({ error: 'Токен и пароль обязательны' }); @@ -465,6 +466,20 @@ router.post('/magic-link/set-password', async (req, res) => { return res.status(400).json({ error: 'Пароль должен быть от 8 до 24 символов' }); } + // Валидация username (обязателен, нельзя менять после регистрации) + if (!username || username.trim().length < 3 || username.trim().length > 20) { + return res.status(400).json({ error: 'Юзернейм обязателен и должен быть от 3 до 20 символов' }); + } + + // Проверить уникальность username (исключая текущего пользователя) + const existingUser = await User.findOne({ + username: username.trim().toLowerCase(), + _id: { $ne: user._id } + }); + if (existingUser) { + return res.status(400).json({ error: 'Этот юзернейм уже занят' }); + } + // Найти пользователя с этим токеном const user = await User.findOne({ magicLinkToken: token, @@ -486,8 +501,14 @@ router.post('/magic-link/set-password', async (req, res) => { user.magicLinkExpires = undefined; user.lastActiveAt = new Date(); - if (username && !user.username) { - user.username = username; + // Устанавливаем username (нельзя менять после регистрации) + if (username) { + user.username = username.trim().toLowerCase(); + } + + // Устанавливаем firstName (никнейм, можно менять) + if (firstName) { + user.firstName = firstName.trim(); } await user.save(); diff --git a/backend/routes/posts.js b/backend/routes/posts.js index 5fa4671..f38a81f 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const { authenticate } = require('../middleware/auth'); +const { authenticate, authenticateOptional } = require('../middleware/auth'); const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter'); const { searchLimiter } = require('../middleware/rateLimiter'); const { validatePostContent, validateTags, validateImageUrl } = require('../middleware/validator'); @@ -46,11 +46,12 @@ router.get('/:id', authenticate, async (req, res) => { } }); -// Получить ленту постов -router.get('/', authenticate, async (req, res) => { +// Получить ленту постов (доступно для гостей) +router.get('/', authenticateOptional, async (req, res) => { try { const { page = 1, limit = 20, tag, userId, filter = 'all' } = req.query; const query = {}; + const isGuest = req.user?.isGuest; // Фильтр по тегу if (tag) { @@ -63,7 +64,7 @@ router.get('/', authenticate, async (req, res) => { } // Фильтры: 'all', 'interests', 'following' - if (filter === 'interests') { + if (filter === 'interests' && !isGuest) { // Лента по интересам - посты с тегами из preferredTags пользователя const user = await User.findById(req.user._id).select('preferredTags'); if (user.preferredTags && user.preferredTags.length > 0) { @@ -76,7 +77,7 @@ router.get('/', authenticate, async (req, res) => { currentPage: page }); } - } else if (filter === 'following') { + } else if (filter === 'following' && !isGuest) { // Лента подписок - посты от пользователей, на которых подписан const user = await User.findById(req.user._id).select('following'); if (user.following && user.following.length > 0) { @@ -90,13 +91,14 @@ router.get('/', authenticate, async (req, res) => { }); } } + // Для гостей или filter === 'all' - показываем все посты без дополнительных фильтров // 'all' - все посты, без дополнительных фильтров // Применить whitelist настройки пользователя (только NSFW и Homo) - if (req.user.settings.whitelist.noNSFW) { + if (req.user?.settings?.whitelist?.noNSFW) { query.isNSFW = false; } - if (req.user.settings.whitelist.noHomo) { + if (req.user?.settings?.whitelist?.noHomo) { // Скрывать только посты, помеченные как гомосексуальные. // Посты без флага (старые) остаются видимыми. query.isHomo = { $ne: true }; diff --git a/backend/routes/users.js b/backend/routes/users.js index 6e19187..19676d3 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const { authenticate } = require('../middleware/auth'); +const { uploadAvatar } = require('../middleware/upload'); const User = require('../models/User'); const Post = require('../models/Post'); const Notification = require('../models/Notification'); @@ -125,14 +126,31 @@ router.post('/:id/follow', authenticate, async (req, res) => { }); // Обновить профиль -router.put('/profile', authenticate, async (req, res) => { +router.put('/profile', authenticate, uploadAvatar, async (req, res) => { try { - const { bio, settings } = req.body; + const { bio, settings, firstName, lastName, onboardingCompleted } = req.body; + + // Обработка загруженной аватарки + if (req.uploadedFiles && req.uploadedFiles.length > 0) { + req.user.photoUrl = req.uploadedFiles[0]; + } if (bio !== undefined) { req.user.bio = bio; } + if (firstName !== undefined) { + req.user.firstName = firstName; + } + + if (lastName !== undefined) { + req.user.lastName = lastName; + } + + if (onboardingCompleted !== undefined) { + req.user.onboardingCompleted = onboardingCompleted; + } + if (settings) { req.user.settings = req.user.settings || {}; diff --git a/backend/utils/email.js b/backend/utils/email.js index 601a2e1..9a9ec20 100644 --- a/backend/utils/email.js +++ b/backend/utils/email.js @@ -12,6 +12,18 @@ const initializeEmailService = () => { const emailProvider = process.env.EMAIL_PROVIDER || 'aws'; // aws, yandex, smtp if (emailProvider === 'aws' && config.email?.aws) { + const accessKeyId = config.email.aws.accessKeyId; + const secretAccessKey = config.email.aws.secretAccessKey; + + // Проверка наличия credentials + if (!accessKeyId || !secretAccessKey) { + console.error('[Email] ❌ AWS SES credentials не установлены!'); + console.error('[Email] Установите AWS_SES_ACCESS_KEY_ID и AWS_SES_SECRET_ACCESS_KEY в .env'); + console.error('[Email] Или используйте EMAIL_PROVIDER=yandex или EMAIL_PROVIDER=smtp'); + sesClient = null; + return; + } + const awsRegion = config.email.aws.region || 'us-east-1'; const validAWSRegions = [ 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', @@ -26,8 +38,8 @@ const initializeEmailService = () => { (isYandexCloud ? 'https://postbox.cloud.yandex.net' : null); const sesConfig = { - accessKeyId: config.email.aws.accessKeyId, - secretAccessKey: config.email.aws.secretAccessKey, + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, region: awsRegion }; @@ -38,17 +50,15 @@ const initializeEmailService = () => { sesConfig.isYandexCloud = true; console.log(`[Email] Используется Yandex Cloud Postbox с endpoint: ${endpointUrl}`); // Не создаем SES клиент для Yandex Cloud, будем использовать прямые HTTP запросы + sesClient = { config: sesConfig, isYandexCloud: true }; } else if (!validAWSRegions.includes(awsRegion)) { console.warn(`[Email] Невалидный регион AWS SES: ${awsRegion}. Используется us-east-1`); sesConfig.region = 'us-east-1'; sesClient = new AWS.SES(sesConfig); + console.log('[Email] ✅ AWS SES клиент инициализирован'); } else { sesClient = new AWS.SES(sesConfig); - } - - // Сохраняем конфигурацию для Yandex Cloud - if (endpointUrl) { - sesClient = { config: sesConfig, isYandexCloud: true }; + console.log('[Email] ✅ AWS SES клиент инициализирован'); } } else if (emailProvider === 'yandex' || emailProvider === 'smtp') { const emailConfig = config.email?.[emailProvider] || config.email?.smtp || {}; @@ -141,7 +151,12 @@ const sendEmail = async (to, subject, html, text) => { const emailProvider = process.env.EMAIL_PROVIDER || 'aws'; const fromEmail = process.env.EMAIL_FROM || config.email?.from || 'noreply@nakama.guru'; - if (emailProvider === 'aws' && sesClient) { + if (emailProvider === 'aws') { + if (!sesClient) { + const errorMsg = 'AWS SES не инициализирован. Проверьте AWS_SES_ACCESS_KEY_ID и AWS_SES_SECRET_ACCESS_KEY в .env файле. Или используйте EMAIL_PROVIDER=yandex или EMAIL_PROVIDER=smtp'; + console.error('[Email] ❌', errorMsg); + throw new Error(errorMsg); + } // Проверка на Yandex Cloud Postbox if (sesClient.isYandexCloud) { // Yandex Cloud Postbox использует SESv2 API - используем прямой HTTP запрос @@ -246,7 +261,11 @@ const sendEmail = async (to, subject, html, text) => { console.error('Ошибка отправки email:', error); // Более информативные сообщения об ошибках - if (error.code === 'EAUTH') { + if (error.code === 'CredentialsError' || (error.message && error.message.includes('Missing credentials'))) { + const errorMsg = 'AWS SES credentials не настроены. Установите AWS_SES_ACCESS_KEY_ID и AWS_SES_SECRET_ACCESS_KEY в .env файле. Или используйте EMAIL_PROVIDER=yandex или EMAIL_PROVIDER=smtp'; + console.error('[Email] ❌', errorMsg); + throw new Error(errorMsg); + } else if (error.code === 'EAUTH') { throw new Error('Неверные учетные данные SMTP. Проверьте YANDEX_SMTP_USER и YANDEX_SMTP_PASSWORD в .env файле. Для Yandex используйте пароль приложения, а не основной пароль.'); } else if (error.code === 'ECONNECTION') { throw new Error('Не удалось подключиться к SMTP серверу. Проверьте YANDEX_SMTP_HOST и YANDEX_SMTP_PORT.'); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 58ad35c..e29a825 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,6 +16,7 @@ import Profile from './pages/Profile' import UserProfile from './pages/UserProfile' import CommentsPage from './pages/CommentsPage' import PostMenuPage from './pages/PostMenuPage' +import VerifyEmail from './pages/VerifyEmail' import MiniPlayer from './components/MiniPlayer' import FullPlayer from './components/FullPlayer' import './styles/index.css' @@ -222,7 +223,7 @@ function AppContent() { }> } /> - } /> + } /> } /> } /> } /> @@ -233,6 +234,7 @@ function AppContent() { } /> } /> + } /> ) } diff --git a/frontend/src/components/FullPlayer.jsx b/frontend/src/components/FullPlayer.jsx index 98e21c6..126be8f 100644 --- a/frontend/src/components/FullPlayer.jsx +++ b/frontend/src/components/FullPlayer.jsx @@ -141,7 +141,9 @@ export default function FullPlayer() { {currentTrack.title} { diff --git a/frontend/src/components/MiniPlayer.jsx b/frontend/src/components/MiniPlayer.jsx index 148d617..f3dc950 100644 --- a/frontend/src/components/MiniPlayer.jsx +++ b/frontend/src/components/MiniPlayer.jsx @@ -48,7 +48,9 @@ export default function MiniPlayer() { {currentTrack.title} { diff --git a/frontend/src/components/MusicAttachment.jsx b/frontend/src/components/MusicAttachment.jsx index d8fd90a..e0675be 100644 --- a/frontend/src/components/MusicAttachment.jsx +++ b/frontend/src/components/MusicAttachment.jsx @@ -27,7 +27,9 @@ export default function MusicAttachment({ track, onRemove, showRemove = false }) {track.title} { diff --git a/frontend/src/components/MusicPickerModal.jsx b/frontend/src/components/MusicPickerModal.jsx index 3078b63..fdd6587 100644 --- a/frontend/src/components/MusicPickerModal.jsx +++ b/frontend/src/components/MusicPickerModal.jsx @@ -64,7 +64,9 @@ export default function MusicPickerModal({ onClose, onSelect }) { {track.title} { diff --git a/frontend/src/contexts/MusicPlayerContext.jsx b/frontend/src/contexts/MusicPlayerContext.jsx index ebf5438..2ae4edd 100644 --- a/frontend/src/contexts/MusicPlayerContext.jsx +++ b/frontend/src/contexts/MusicPlayerContext.jsx @@ -113,8 +113,10 @@ export const MusicPlayerProvider = ({ children }) => { // Если относительный URL (локальное хранилище), добавить базовый URL if (audioUrl.startsWith('/uploads/')) { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api' - audioUrl = apiUrl.replace('/api', '') + audioUrl + const baseUrl = import.meta.env.DEV + ? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + : (import.meta.env.VITE_API_URL || 'https://nkm.guru/api').replace('/api', '') + audioUrl = baseUrl + audioUrl } // Убедиться что URL валидный diff --git a/frontend/src/pages/Feed.css b/frontend/src/pages/Feed.css index 23f78ab..d4e650d 100644 --- a/frontend/src/pages/Feed.css +++ b/frontend/src/pages/Feed.css @@ -30,12 +30,39 @@ align-items: center; justify-content: center; box-shadow: 0 2px 8px var(--shadow-md); + border: none; + cursor: pointer; + transition: transform 0.2s ease; +} + +.create-btn:hover { + transform: scale(1.05); +} + +.create-btn:active { + transform: scale(0.95); } .create-btn svg { stroke: white; } +.create-btn.auth-btn { + width: auto; + height: 36px; + padding: 0 16px; + border-radius: 18px; + font-size: 15px; + font-weight: 600; + background: var(--button-accent); + color: white; +} + +.create-btn.auth-btn:hover { + opacity: 0.9; + transform: none; +} + [data-theme="dark"] .create-btn { background: #FFFFFF; color: #000000; @@ -45,6 +72,11 @@ stroke: #000000; } +[data-theme="dark"] .create-btn.auth-btn { + background: var(--button-accent); + color: white; +} + .feed-filters { display: flex; gap: 8px; diff --git a/frontend/src/pages/Feed.jsx b/frontend/src/pages/Feed.jsx index d8c0bf3..9125478 100644 --- a/frontend/src/pages/Feed.jsx +++ b/frontend/src/pages/Feed.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useSearchParams, useNavigate } from 'react-router-dom' -import { getPosts, getPost } from '../utils/api' +import { getPosts, getPost, updateProfile } from '../utils/api' import PostCard from '../components/PostCard' import CreatePostModal from '../components/CreatePostModal' import OnboardingPost from '../components/OnboardingPost' @@ -9,7 +9,7 @@ import { Plus, Settings } from 'lucide-react' import { hapticFeedback } from '../utils/telegram' import './Feed.css' -export default function Feed({ user }) { +export default function Feed({ user, setUser }) { const [searchParams] = useSearchParams() const navigate = useNavigate() const [posts, setPosts] = useState([]) @@ -21,12 +21,22 @@ export default function Feed({ user }) { const [page, setPage] = useState(1) const [hasMore, setHasMore] = useState(true) const [highlightPostId, setHighlightPostId] = useState(null) + // Проверяем, показывалось ли приветствие ранее - только 1 раз (из БД) const [onboardingVisible, setOnboardingVisible] = useState({ - welcome: true, - tags: true, - media: true + welcome: !user?.onboardingCompleted && (user?.isGuest || !user?.onboardingCompleted), + tags: false, // Удаляем второй пост + media: false }) + useEffect(() => { + // Обновляем onboarding при изменении user (из БД) + setOnboardingVisible({ + welcome: !user?.onboardingCompleted && (user?.isGuest || !user?.onboardingCompleted), + tags: false, + media: false + }) + }, [user]) + useEffect(() => { // Проверить параметр post в URL const postId = searchParams.get('post') @@ -155,7 +165,7 @@ export default function Feed({ user }) { setShowCreateModal(false) } - const handleOnboardingAction = (type) => { + const handleOnboardingAction = async (type) => { hapticFeedback('light') if (type === 'welcome' || type === 'tags') { @@ -164,18 +174,39 @@ export default function Feed({ user }) { navigate('/media') } + // Сохраняем в БД, если пользователь авторизован + if (type === 'welcome' && !user?.isGuest && user?.id) { + try { + await updateProfile({ onboardingCompleted: true }) + // Обновляем локальное состояние пользователя + if (setUser) { + setUser({ ...user, onboardingCompleted: true }) + } + } catch (error) { + console.error('Ошибка сохранения onboarding:', error) + } + } + setOnboardingVisible(prev => ({ ...prev, [type]: false })) - const dismissed = JSON.parse(localStorage.getItem('onboarding_dismissed') || '{}') - dismissed[type] = true - localStorage.setItem('onboarding_dismissed', JSON.stringify(dismissed)) } - const handleOnboardingDismiss = (type) => { + const handleOnboardingDismiss = async (type) => { hapticFeedback('light') + + // Сохраняем в БД, если пользователь авторизован + if (type === 'welcome' && !user?.isGuest && user?.id) { + try { + await updateProfile({ onboardingCompleted: true }) + // Обновляем локальное состояние пользователя + if (setUser) { + setUser({ ...user, onboardingCompleted: true }) + } + } catch (error) { + console.error('Ошибка сохранения onboarding:', error) + } + } + setOnboardingVisible(prev => ({ ...prev, [type]: false })) - const dismissed = JSON.parse(localStorage.getItem('onboarding_dismissed') || '{}') - dismissed[type] = true - localStorage.setItem('onboarding_dismissed', JSON.stringify(dismissed)) } const handleLoadMore = () => { @@ -189,9 +220,23 @@ export default function Feed({ user }) { {/* Хедер */}

Nakama

- + {user?.isGuest ? ( + + ) : ( + + )}
{/* Фильтры */} @@ -227,7 +272,7 @@ export default function Feed({ user }) { {/* Посты */}
- {/* Onboarding посты для новых пользователей и гостей */} + {/* Onboarding пост "Добро пожаловать" - показывается только 1 раз */} {(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.welcome && ( )} - {(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.tags && posts.length > 2 && ( - handleOnboardingAction('tags')} - onDismiss={() => handleOnboardingDismiss('tags')} - /> - )} - {loading && posts.length === 0 ? (
diff --git a/frontend/src/pages/MediaMusic.jsx b/frontend/src/pages/MediaMusic.jsx index cfe02b4..93ca2ef 100644 --- a/frontend/src/pages/MediaMusic.jsx +++ b/frontend/src/pages/MediaMusic.jsx @@ -224,7 +224,9 @@ export default function MediaMusic({ user }) { {track.title} { diff --git a/frontend/src/pages/Profile.css b/frontend/src/pages/Profile.css index 1e0f0e7..eab431d 100644 --- a/frontend/src/pages/Profile.css +++ b/frontend/src/pages/Profile.css @@ -746,3 +746,255 @@ line-height: 1.4; } + +/* Привязка email */ +.link-email-card { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 12px; +} + +.link-email-content { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.link-email-icon { + width: 36px; + height: 36px; + border-radius: 12px; + background: rgba(0, 122, 255, 0.12); + color: #007AFF; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.link-email-text h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.link-email-text p { + margin: 4px 0 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.4; +} + +.link-email-button { + width: 100%; + padding: 12px 18px; + border-radius: 12px; + background: var(--button-accent); + color: white; + border: none; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.link-email-button:hover { + opacity: 0.9; +} + +.link-email-button:active { + opacity: 0.85; +} + +/* Модалка привязки email */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.modal-content { + background: var(--bg-primary); + border-radius: 16px; + width: 100%; + max-width: 400px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid var(--divider-color); +} + +.modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} + +.close-btn { + width: 32px; + height: 32px; + border-radius: 8px; + background: transparent; + border: none; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease; +} + +.close-btn:hover { + background: var(--bg-secondary); +} + +.modal-body { + padding: 20px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.form-input { + width: 100%; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--divider-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 15px; + transition: border-color 0.2s ease; +} + +.form-input:focus { + outline: none; + border-color: var(--button-accent); +} + +.form-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.form-hint { + margin: 6px 0 0; + font-size: 12px; + color: var(--text-secondary); +} + +.error-message { + padding: 12px; + border-radius: 12px; + background: rgba(255, 59, 48, 0.1); + color: #FF3B30; + font-size: 14px; + margin-bottom: 16px; +} + +.modal-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.btn-secondary { + flex: 1; + padding: 12px; + border-radius: 12px; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--divider-color); + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; +} + +.btn-secondary:hover:not(:disabled) { + background: var(--bg-tertiary); +} + +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + flex: 1; + padding: 12px; + border-radius: 12px; + background: var(--button-accent); + color: white; + border: none; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.btn-primary:hover:not(:disabled) { + opacity: 0.9; +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Загрузка аватарки */ +.avatar-wrapper { + position: relative; + display: inline-block; +} + +.avatar-upload-btn { + position: absolute; + bottom: 0; + right: 0; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--button-accent); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px solid var(--bg-primary); + transition: transform 0.2s ease; +} + +.avatar-upload-btn:hover { + transform: scale(1.1); +} + +.avatar-upload-btn:active { + transform: scale(0.95); +} diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index 6c090e9..1c4c319 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { Settings, Heart, Edit2, Shield, UserPlus, Copy, Info, Tag, X } from 'lucide-react' import { createPortal } from 'react-dom' -import { updateProfile, getTags, getPreferredTags, updatePreferredTags, autocompleteTags } from '../utils/api' +import { updateProfile, getTags, getPreferredTags, updatePreferredTags, autocompleteTags, linkEmail } from '../utils/api' import { hapticFeedback } from '../utils/telegram' import ThemeToggle from '../components/ThemeToggle' import FollowListModal from '../components/FollowListModal' @@ -66,6 +66,39 @@ export default function Profile({ user, setUser }) { // Для привязки email const [linkEmailData, setLinkEmailData] = useState({ email: '', password: '' }) + const [linkEmailLoading, setLinkEmailLoading] = useState(false) + const [linkEmailError, setLinkEmailError] = useState('') + + const handleLinkEmail = async () => { + if (!linkEmailData.email || !linkEmailData.password) { + setLinkEmailError('Заполните все поля') + return + } + + if (linkEmailData.password.length < 8) { + setLinkEmailError('Пароль должен быть не менее 8 символов') + return + } + + try { + setLinkEmailLoading(true) + setLinkEmailError('') + hapticFeedback('light') + + const result = await linkEmail(linkEmailData.email, linkEmailData.password) + + setUser({ ...user, email: linkEmailData.email }) + setShowLinkEmail(false) + setLinkEmailData({ email: '', password: '' }) + hapticFeedback('success') + } catch (error) { + console.error('Ошибка привязки email:', error) + setLinkEmailError(error.response?.data?.error || 'Ошибка привязки email') + hapticFeedback('error') + } finally { + setLinkEmailLoading(false) + } + } const handleSaveBio = async () => { try { @@ -331,11 +364,45 @@ export default function Profile({ user, setUser }) { {/* Информация о пользователе */}
- {user.username +
+ {user.username + {/* Кнопка загрузки аватарки только для веб версии (без telegramId) */} + {!user.telegramId && ( + + )} +

@@ -812,6 +879,79 @@ export default function Profile({ user, setUser }) {

)} + + {/* Модалка привязки email */} + {showLinkEmail && ( +
{ + setShowLinkEmail(false) + setLinkEmailError('') + }}> +
e.stopPropagation()}> +
+

Привязать email

+ +
+
+
+ + { + setLinkEmailData({ ...linkEmailData, email: e.target.value }) + setLinkEmailError('') + }} + className="form-input" + disabled={linkEmailLoading} + /> +
+
+ + { + setLinkEmailData({ ...linkEmailData, password: e.target.value }) + setLinkEmailError('') + }} + className="form-input" + disabled={linkEmailLoading} + /> +

Пароль должен быть от 8 до 24 символов

+
+ {linkEmailError && ( +
{linkEmailError}
+ )} +
+ + +
+
+
+
+ )}
) } diff --git a/frontend/src/pages/VerifyEmail.css b/frontend/src/pages/VerifyEmail.css new file mode 100644 index 0000000..93d367a --- /dev/null +++ b/frontend/src/pages/VerifyEmail.css @@ -0,0 +1,135 @@ +.verify-email-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: var(--bg-primary); +} + +.verify-email-container { + width: 100%; + max-width: 400px; +} + +.verify-email-card { + background: var(--bg-secondary); + border-radius: 16px; + padding: 24px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.verify-email-card h2 { + margin: 0 0 8px; + font-size: 24px; + font-weight: 700; + color: var(--text-primary); +} + +.verify-email-subtitle { + margin: 0 0 24px; + font-size: 14px; + color: var(--text-secondary); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--divider-color); + border-top-color: var(--button-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.error-state { + text-align: center; + padding: 24px; +} + +.error-state h2 { + margin: 0 0 12px; + font-size: 20px; + color: #FF3B30; +} + +.error-state p { + margin: 0 0 24px; + color: var(--text-secondary); +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.form-input { + width: 100%; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--divider-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 15px; + transition: border-color 0.2s ease; + box-sizing: border-box; +} + +.form-input:focus { + outline: none; + border-color: var(--button-accent); +} + +.form-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.form-hint { + margin: 6px 0 0; + font-size: 12px; + color: var(--text-secondary); +} + +.error-message { + padding: 12px; + border-radius: 12px; + background: rgba(255, 59, 48, 0.1); + color: #FF3B30; + font-size: 14px; + margin-bottom: 16px; +} + +.btn-primary { + width: 100%; + padding: 14px; + border-radius: 12px; + background: var(--button-accent); + color: white; + border: none; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.btn-primary:hover:not(:disabled) { + opacity: 0.9; +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + diff --git a/frontend/src/pages/VerifyEmail.jsx b/frontend/src/pages/VerifyEmail.jsx new file mode 100644 index 0000000..7ea6b53 --- /dev/null +++ b/frontend/src/pages/VerifyEmail.jsx @@ -0,0 +1,219 @@ +import { useState, useEffect } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' +import { verifyMagicLink, setPassword } from '../utils/api' +import { X } from 'lucide-react' +import './VerifyEmail.css' + +export default function VerifyEmail() { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const token = searchParams.get('token') + + const [loading, setLoading] = useState(true) + const [requiresPassword, setRequiresPassword] = useState(false) + const [error, setError] = useState('') + const [formData, setFormData] = useState({ + password: '', + confirmPassword: '', + username: '', + firstName: '' + }) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!token) { + setError('Токен не указан') + setLoading(false) + return + } + + const checkToken = async () => { + try { + const result = await verifyMagicLink(token) + if (result.requiresPassword) { + setRequiresPassword(true) + } else { + // Уже авторизован, перенаправляем + window.location.href = '/feed' + } + } catch (err) { + setError(err.response?.data?.error || 'Неверная или устаревшая ссылка') + } finally { + setLoading(false) + } + } + + checkToken() + }, [token]) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + // Валидация + if (!formData.password || formData.password.length < 8) { + setError('Пароль должен быть не менее 8 символов') + return + } + + if (formData.password !== formData.confirmPassword) { + setError('Пароли не совпадают') + return + } + + if (!formData.username || formData.username.trim().length < 3) { + setError('Юзернейм должен быть не менее 3 символов') + return + } + + if (!formData.firstName || formData.firstName.trim().length < 1) { + setError('Никнейм обязателен') + return + } + + try { + setSubmitting(true) + await setPassword( + token, + formData.password, + formData.username.trim().toLowerCase(), + formData.firstName.trim() + ) + + // Успешная регистрация, перенаправляем + window.location.href = '/feed' + } catch (err) { + setError(err.response?.data?.error || 'Ошибка регистрации') + } finally { + setSubmitting(false) + } + } + + if (loading) { + return ( +
+
+
+

Проверка ссылки...

+
+
+ ) + } + + if (error && !requiresPassword) { + return ( +
+
+
+

Ошибка

+

{error}

+ +
+
+
+ ) + } + + if (requiresPassword) { + return ( +
+
+
+

Завершение регистрации

+

Установите пароль и заполните данные профиля

+ +
+
+ + { + setFormData({ ...formData, username: e.target.value.replace(/[^a-z0-9_]/gi, '').toLowerCase() }) + setError('') + }} + disabled={submitting} + required + minLength={3} + maxLength={20} + /> +

Только латинские буквы, цифры и _. Нельзя изменить после регистрации

+
+ +
+ + { + setFormData({ ...formData, firstName: e.target.value }) + setError('') + }} + disabled={submitting} + required + maxLength={50} + /> +

Можно изменить в настройках профиля

+
+ +
+ + { + setFormData({ ...formData, password: e.target.value }) + setError('') + }} + disabled={submitting} + required + minLength={8} + maxLength={24} + /> +
+ +
+ + { + setFormData({ ...formData, confirmPassword: e.target.value }) + setError('') + }} + disabled={submitting} + required + /> +
+ + {error && ( +
{error}
+ )} + + +
+
+
+
+ ) + } + + return null +} + diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 6701f37..3b01dd2 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1,11 +1,10 @@ import axios from 'axios' // API URL из переменных окружения -const API_URL = import.meta.env.VITE_API_URL || ( - import.meta.env.DEV - ? 'http://localhost:3000/api' - : '/api' // Для production используем относительный путь -) +// Если frontend и API на разных доменах, используем абсолютный URL +const API_URL = import.meta.env.DEV + ? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api') + : (import.meta.env.VITE_API_URL || 'https://nkm.guru/api') // Для production используем новый домен API // Создать инстанс axios с настройками const api = axios.create({ @@ -125,8 +124,8 @@ export const verifyMagicLink = async (token) => { return response.data } -export const setPassword = async (token, password, username) => { - const response = await api.post('/auth/magic-link/set-password', { token, password, username }) +export const setPassword = async (token, password, username, firstName) => { + const response = await api.post('/auth/magic-link/set-password', { token, password, username, firstName }) return response.data } @@ -224,6 +223,24 @@ export const unfollowUser = async (userId) => { } export const updateProfile = async (data) => { + // Если есть файл аватарки, отправляем как FormData + if (data.avatar instanceof File) { + const formData = new FormData() + formData.append('avatar', data.avatar) + if (data.bio !== undefined) formData.append('bio', data.bio) + if (data.firstName !== undefined) formData.append('firstName', data.firstName) + if (data.lastName !== undefined) formData.append('lastName', data.lastName) + if (data.settings) formData.append('settings', JSON.stringify(data.settings)) + + const response = await api.put('/users/profile', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + return response.data + } + + // Обычный JSON запрос const response = await api.put('/users/profile', data) return response.data }