Update files
This commit is contained in:
parent
200146fe4e
commit
85bc6a1ad9
|
|
@ -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 будет проксировать запросы на новый домен.
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Routes>
|
||||
<Route path="/" element={<Layout user={user} />}>
|
||||
<Route index element={<Navigate to="/feed" replace />} />
|
||||
<Route path="feed" element={<Feed user={user} />} />
|
||||
<Route path="feed" element={<Feed user={user} setUser={setUser} />} />
|
||||
<Route path="media" element={<Media user={user} />} />
|
||||
<Route path="media/furry" element={<MediaFurry user={user} />} />
|
||||
<Route path="media/anime" element={<MediaAnime user={user} />} />
|
||||
|
|
@ -233,6 +234,7 @@ function AppContent() {
|
|||
<Route path="post/:postId/comments" element={<CommentsPage user={user} />} />
|
||||
<Route path="post/:postId/menu" element={<PostMenuPage user={user} />} />
|
||||
</Route>
|
||||
<Route path="auth/verify" element={<VerifyEmail />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,7 +141,9 @@ export default function FullPlayer() {
|
|||
<img
|
||||
src={currentTrack.coverImage.startsWith('http')
|
||||
? currentTrack.coverImage
|
||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + currentTrack.coverImage
|
||||
: (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', '')) + currentTrack.coverImage
|
||||
}
|
||||
alt={currentTrack.title}
|
||||
onError={(e) => {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ export default function MiniPlayer() {
|
|||
<img
|
||||
src={currentTrack.coverImage.startsWith('http')
|
||||
? currentTrack.coverImage
|
||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + currentTrack.coverImage
|
||||
: (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', '')) + currentTrack.coverImage
|
||||
}
|
||||
alt={currentTrack.title}
|
||||
onError={(e) => {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ export default function MusicAttachment({ track, onRemove, showRemove = false })
|
|||
<img
|
||||
src={track.coverImage.startsWith('http')
|
||||
? track.coverImage
|
||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
|
||||
: (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', '')) + track.coverImage
|
||||
}
|
||||
alt={track.title}
|
||||
onError={(e) => {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ export default function MusicPickerModal({ onClose, onSelect }) {
|
|||
<img
|
||||
src={track.coverImage.startsWith('http')
|
||||
? track.coverImage
|
||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
|
||||
: (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', '')) + track.coverImage
|
||||
}
|
||||
alt={track.title}
|
||||
onError={(e) => {
|
||||
|
|
|
|||
|
|
@ -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 валидный
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
{/* Хедер */}
|
||||
<div className="feed-header">
|
||||
<h1>Nakama</h1>
|
||||
<button className="create-btn" onClick={handleCreatePost}>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
{user?.isGuest ? (
|
||||
<button
|
||||
className="create-btn auth-btn"
|
||||
onClick={() => {
|
||||
setAuthReason('Войдите, чтобы публиковать посты')
|
||||
setShowAuthModal(true)
|
||||
hapticFeedback('light')
|
||||
}}
|
||||
title="Войти"
|
||||
>
|
||||
Войти
|
||||
</button>
|
||||
) : (
|
||||
<button className="create-btn" onClick={handleCreatePost}>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
|
|
@ -227,7 +272,7 @@ export default function Feed({ user }) {
|
|||
|
||||
{/* Посты */}
|
||||
<div className="feed-content">
|
||||
{/* Onboarding посты для новых пользователей и гостей */}
|
||||
{/* Onboarding пост "Добро пожаловать" - показывается только 1 раз */}
|
||||
{(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.welcome && (
|
||||
<OnboardingPost
|
||||
type="welcome"
|
||||
|
|
@ -236,14 +281,6 @@ export default function Feed({ user }) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.tags && posts.length > 2 && (
|
||||
<OnboardingPost
|
||||
type="tags"
|
||||
onAction={() => handleOnboardingAction('tags')}
|
||||
onDismiss={() => handleOnboardingDismiss('tags')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && posts.length === 0 ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
|
|
|
|||
|
|
@ -224,7 +224,9 @@ export default function MediaMusic({ user }) {
|
|||
<img
|
||||
src={track.coverImage.startsWith('http')
|
||||
? track.coverImage
|
||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
|
||||
: (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', '')) + track.coverImage
|
||||
}
|
||||
alt={track.title}
|
||||
onError={(e) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
|
||||
{/* Информация о пользователе */}
|
||||
<div className="profile-info card">
|
||||
<img
|
||||
src={user.photoUrl || '/default-avatar.png'}
|
||||
alt={user.username || user.firstName || 'User'}
|
||||
className="profile-avatar"
|
||||
/>
|
||||
<div className="avatar-wrapper">
|
||||
<img
|
||||
src={user.photoUrl || '/default-avatar.png'}
|
||||
alt={user.username || user.firstName || 'User'}
|
||||
className="profile-avatar"
|
||||
/>
|
||||
{/* Кнопка загрузки аватарки только для веб версии (без telegramId) */}
|
||||
{!user.telegramId && (
|
||||
<label className="avatar-upload-btn">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
// Проверка размера (макс 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Файл слишком большой. Максимальный размер: 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
hapticFeedback('light')
|
||||
const result = await updateProfile({ avatar: file })
|
||||
setUser({ ...user, photoUrl: result.user.photoUrl })
|
||||
hapticFeedback('success')
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки аватарки:', error)
|
||||
alert(error.response?.data?.error || 'Ошибка загрузки аватарки')
|
||||
hapticFeedback('error')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Edit2 size={16} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profile-details">
|
||||
<h2 className="profile-name">
|
||||
|
|
@ -812,6 +879,79 @@ export default function Profile({ user, setUser }) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модалка привязки email */}
|
||||
{showLinkEmail && (
|
||||
<div className="modal-overlay" onClick={() => {
|
||||
setShowLinkEmail(false)
|
||||
setLinkEmailError('')
|
||||
}}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Привязать email</h2>
|
||||
<button className="close-btn" onClick={() => {
|
||||
setShowLinkEmail(false)
|
||||
setLinkEmailError('')
|
||||
}}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={linkEmailData.email}
|
||||
onChange={e => {
|
||||
setLinkEmailData({ ...linkEmailData, email: e.target.value })
|
||||
setLinkEmailError('')
|
||||
}}
|
||||
className="form-input"
|
||||
disabled={linkEmailLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Минимум 8 символов"
|
||||
value={linkEmailData.password}
|
||||
onChange={e => {
|
||||
setLinkEmailData({ ...linkEmailData, password: e.target.value })
|
||||
setLinkEmailError('')
|
||||
}}
|
||||
className="form-input"
|
||||
disabled={linkEmailLoading}
|
||||
/>
|
||||
<p className="form-hint">Пароль должен быть от 8 до 24 символов</p>
|
||||
</div>
|
||||
{linkEmailError && (
|
||||
<div className="error-message">{linkEmailError}</div>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setShowLinkEmail(false)
|
||||
setLinkEmailError('')
|
||||
}}
|
||||
disabled={linkEmailLoading}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleLinkEmail}
|
||||
disabled={linkEmailLoading || !linkEmailData.email || !linkEmailData.password}
|
||||
>
|
||||
{linkEmailLoading ? 'Привязка...' : 'Привязать'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="verify-email-page">
|
||||
<div className="verify-email-container">
|
||||
<div className="spinner" />
|
||||
<p>Проверка ссылки...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !requiresPassword) {
|
||||
return (
|
||||
<div className="verify-email-page">
|
||||
<div className="verify-email-container">
|
||||
<div className="error-state">
|
||||
<h2>Ошибка</h2>
|
||||
<p>{error}</p>
|
||||
<button className="btn-primary" onClick={() => navigate('/feed')}>
|
||||
На главную
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (requiresPassword) {
|
||||
return (
|
||||
<div className="verify-email-page">
|
||||
<div className="verify-email-container">
|
||||
<div className="verify-email-card">
|
||||
<h2>Завершение регистрации</h2>
|
||||
<p className="verify-email-subtitle">Установите пароль и заполните данные профиля</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Юзернейм *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="username"
|
||||
value={formData.username}
|
||||
onChange={e => {
|
||||
setFormData({ ...formData, username: e.target.value.replace(/[^a-z0-9_]/gi, '').toLowerCase() })
|
||||
setError('')
|
||||
}}
|
||||
disabled={submitting}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={20}
|
||||
/>
|
||||
<p className="form-hint">Только латинские буквы, цифры и _. Нельзя изменить после регистрации</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Никнейм *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Ваше имя"
|
||||
value={formData.firstName}
|
||||
onChange={e => {
|
||||
setFormData({ ...formData, firstName: e.target.value })
|
||||
setError('')
|
||||
}}
|
||||
disabled={submitting}
|
||||
required
|
||||
maxLength={50}
|
||||
/>
|
||||
<p className="form-hint">Можно изменить в настройках профиля</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Пароль *</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="Минимум 8 символов"
|
||||
value={formData.password}
|
||||
onChange={e => {
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
setError('')
|
||||
}}
|
||||
disabled={submitting}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={24}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Подтвердите пароль *</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="Повторите пароль"
|
||||
value={formData.confirmPassword}
|
||||
onChange={e => {
|
||||
setFormData({ ...formData, confirmPassword: e.target.value })
|
||||
setError('')
|
||||
}}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting || !formData.password || !formData.username || !formData.firstName}
|
||||
>
|
||||
{submitting ? 'Регистрация...' : 'Завершить регистрацию'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue