v2.0.0: Multi-user authentication with JWT
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 16s
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 16s
Features: - User registration and login with JWT tokens - All data is now user-specific (multi-tenancy) - Profile page with integrations and logout - Automatic migration of existing data to first user Backend changes: - Added users and refresh_tokens tables - Added user_id to all data tables (projects, entries, nodes, dictionaries, words, progress, configs, telegram_integrations, weekly_goals) - JWT authentication middleware - claimOrphanedData() for data migration Frontend changes: - AuthContext for state management - Login/Register forms - Profile page (replaced Integrations) - All API calls use authFetch with Bearer token Migration notes: - On first deploy, backend automatically adds user_id columns - First user to login claims all existing data
This commit is contained in:
@@ -47,6 +47,15 @@ WEBHOOK_BASE_URL=https://your-domain.com
|
|||||||
# Оставьте пустым, если не хотите использовать проверку секрета
|
# Оставьте пустым, если не хотите использовать проверку секрета
|
||||||
TODOIST_WEBHOOK_SECRET=
|
TODOIST_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Authentication Configuration
|
||||||
|
# ============================================
|
||||||
|
# Секретный ключ для подписи JWT токенов
|
||||||
|
# ВАЖНО: Обязательно задайте свой уникальный секретный ключ для production!
|
||||||
|
# Если не задан, будет использован случайно сгенерированный (не рекомендуется для production)
|
||||||
|
# Можно сгенерировать с помощью: openssl rand -base64 32
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Scheduler Configuration
|
# Scheduler Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
module play-eng-backend
|
module play-eng-backend
|
||||||
|
|
||||||
go 1.21
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
@@ -8,3 +10,5 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
128
play-life-backend/migrations/009_add_users_and_multitenancy.sql
Normal file
128
play-life-backend/migrations/009_add_users_and_multitenancy.sql
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
-- Migration: Add users table and user_id to all tables for multi-tenancy
|
||||||
|
-- This script adds user authentication and makes all data user-specific
|
||||||
|
-- All statements use IF NOT EXISTS / IF EXISTS for idempotency
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: users
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_login_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: refresh_tokens
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to projects
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
|
||||||
|
|
||||||
|
-- Drop old unique constraint (name now unique per user, handled in app)
|
||||||
|
ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to entries
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE entries
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to dictionaries
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE dictionaries
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dictionaries_user_id ON dictionaries(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to words
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE words
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_words_user_id ON words(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to progress
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE progress
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_progress_user_id ON progress(user_id);
|
||||||
|
|
||||||
|
-- Drop old unique constraint (word_id now unique per user)
|
||||||
|
ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key;
|
||||||
|
|
||||||
|
-- Create new unique constraint per user
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to configs
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE configs
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_configs_user_id ON configs(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to telegram_integrations
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_integrations_user_id ON telegram_integrations(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to weekly_goals
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE weekly_goals
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_goals_user_id ON weekly_goals(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to nodes (score data)
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE nodes
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_user_id ON nodes(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE users IS 'Users table for authentication and multi-tenancy';
|
||||||
|
COMMENT ON COLUMN users.email IS 'User email address (unique, used for login)';
|
||||||
|
COMMENT ON COLUMN users.password_hash IS 'Bcrypt hashed password';
|
||||||
|
COMMENT ON COLUMN users.name IS 'User display name';
|
||||||
|
COMMENT ON COLUMN users.is_active IS 'Whether the user account is active';
|
||||||
|
COMMENT ON TABLE refresh_tokens IS 'JWT refresh tokens for persistent login';
|
||||||
|
|
||||||
|
-- Note: The first user who logs in will automatically become the owner of all
|
||||||
|
-- existing data (projects, entries, dictionaries, words, etc.) that have NULL user_id.
|
||||||
|
-- This is handled in the application code (claimOrphanedData function).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "1.1.1",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -7,13 +7,30 @@ import AddWords from './components/AddWords'
|
|||||||
import TestConfigSelection from './components/TestConfigSelection'
|
import TestConfigSelection from './components/TestConfigSelection'
|
||||||
import AddConfig from './components/AddConfig'
|
import AddConfig from './components/AddConfig'
|
||||||
import TestWords from './components/TestWords'
|
import TestWords from './components/TestWords'
|
||||||
import Integrations from './components/Integrations'
|
import Profile from './components/Profile'
|
||||||
|
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
||||||
|
import AuthScreen from './components/auth/AuthScreen'
|
||||||
|
|
||||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||||
const CURRENT_WEEK_API_URL = '/playlife-feed'
|
const CURRENT_WEEK_API_URL = '/playlife-feed'
|
||||||
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||||||
|
|
||||||
function App() {
|
function AppContent() {
|
||||||
|
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||||||
|
<div className="text-white text-xl">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show auth screen if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <AuthScreen />
|
||||||
|
}
|
||||||
const [activeTab, setActiveTab] = useState('current')
|
const [activeTab, setActiveTab] = useState('current')
|
||||||
const [selectedProject, setSelectedProject] = useState(null)
|
const [selectedProject, setSelectedProject] = useState(null)
|
||||||
const [loadedTabs, setLoadedTabs] = useState({
|
const [loadedTabs, setLoadedTabs] = useState({
|
||||||
@@ -25,7 +42,7 @@ function App() {
|
|||||||
'test-config': false,
|
'test-config': false,
|
||||||
'add-config': false,
|
'add-config': false,
|
||||||
test: false,
|
test: false,
|
||||||
integrations: false,
|
profile: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||||
@@ -38,7 +55,7 @@ function App() {
|
|||||||
'test-config': false,
|
'test-config': false,
|
||||||
'add-config': false,
|
'add-config': false,
|
||||||
test: false,
|
test: false,
|
||||||
integrations: false,
|
profile: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Параметры для навигации между вкладками
|
// Параметры для навигации между вкладками
|
||||||
@@ -77,7 +94,7 @@ function App() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const savedTab = window.localStorage?.getItem('activeTab')
|
const savedTab = window.localStorage?.getItem('activeTab')
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'integrations']
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'profile']
|
||||||
if (savedTab && validTabs.includes(savedTab)) {
|
if (savedTab && validTabs.includes(savedTab)) {
|
||||||
setActiveTab(savedTab)
|
setActiveTab(savedTab)
|
||||||
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
||||||
@@ -104,7 +121,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
setCurrentWeekError(null)
|
setCurrentWeekError(null)
|
||||||
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
|
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
|
||||||
const response = await fetch(CURRENT_WEEK_API_URL)
|
const response = await authFetch(CURRENT_WEEK_API_URL)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка загрузки данных')
|
throw new Error('Ошибка загрузки данных')
|
||||||
}
|
}
|
||||||
@@ -149,7 +166,7 @@ function App() {
|
|||||||
setCurrentWeekLoading(false)
|
setCurrentWeekLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [authFetch])
|
||||||
|
|
||||||
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
|
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
|
||||||
try {
|
try {
|
||||||
@@ -159,7 +176,7 @@ function App() {
|
|||||||
setFullStatisticsLoading(true)
|
setFullStatisticsLoading(true)
|
||||||
}
|
}
|
||||||
setFullStatisticsError(null)
|
setFullStatisticsError(null)
|
||||||
const response = await fetch(FULL_STATISTICS_API_URL)
|
const response = await authFetch(FULL_STATISTICS_API_URL)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка загрузки данных')
|
throw new Error('Ошибка загрузки данных')
|
||||||
}
|
}
|
||||||
@@ -175,7 +192,7 @@ function App() {
|
|||||||
setFullStatisticsLoading(false)
|
setFullStatisticsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [authFetch])
|
||||||
|
|
||||||
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
||||||
const tabsInitializedRef = useRef({
|
const tabsInitializedRef = useRef({
|
||||||
@@ -187,7 +204,7 @@ function App() {
|
|||||||
'test-config': false,
|
'test-config': false,
|
||||||
'add-config': false,
|
'add-config': false,
|
||||||
test: false,
|
test: false,
|
||||||
integrations: false,
|
profile: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
||||||
@@ -476,9 +493,9 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loadedTabs.integrations && (
|
{loadedTabs.profile && (
|
||||||
<div className={activeTab === 'integrations' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
|
||||||
<Integrations onNavigate={handleNavigate} />
|
<Profile onNavigate={handleNavigate} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -530,22 +547,21 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('integrations')}
|
onClick={() => handleTabChange('profile')}
|
||||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
activeTab === 'integrations'
|
activeTab === 'profile'
|
||||||
? 'text-indigo-700 bg-white/50'
|
? 'text-indigo-700 bg-white/50'
|
||||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||||
}`}
|
}`}
|
||||||
title="Интеграции"
|
title="Профиль"
|
||||||
>
|
>
|
||||||
<span className="relative z-10 flex items-center justify-center">
|
<span className="relative z-10 flex items-center justify-center">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{activeTab === 'integrations' && (
|
{activeTab === 'profile' && (
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -556,6 +572,14 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './AddConfig.css'
|
import './AddConfig.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [tryMessage, setTryMessage] = useState('')
|
const [tryMessage, setTryMessage] = useState('')
|
||||||
const [wordsCount, setWordsCount] = useState('10')
|
const [wordsCount, setWordsCount] = useState('10')
|
||||||
@@ -19,7 +21,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
|||||||
const loadDictionaries = async () => {
|
const loadDictionaries = async () => {
|
||||||
setLoadingDictionaries(true)
|
setLoadingDictionaries(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
|
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке словарей')
|
throw new Error('Ошибка при загрузке словарей')
|
||||||
}
|
}
|
||||||
@@ -39,7 +41,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
|||||||
const loadSelectedDictionaries = async () => {
|
const loadSelectedDictionaries = async () => {
|
||||||
if (initialEditingConfig?.id) {
|
if (initialEditingConfig?.id) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
|
const response = await authFetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
|
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
|
||||||
@@ -100,7 +102,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
|||||||
: `${API_URL}/configs`
|
: `${API_URL}/configs`
|
||||||
const method = initialEditingConfig ? 'PUT' : 'POST'
|
const method = initialEditingConfig ? 'PUT' : 'POST'
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await authFetch(url, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './AddWords.css'
|
import './AddWords.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [markdownText, setMarkdownText] = useState('')
|
const [markdownText, setMarkdownText] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -81,7 +83,7 @@ function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
|||||||
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
|
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/words`, {
|
const response = await authFetch(`${API_URL}/words`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import React, { useState } from 'react'
|
|
||||||
import TodoistIntegration from './TodoistIntegration'
|
|
||||||
import TelegramIntegration from './TelegramIntegration'
|
|
||||||
|
|
||||||
function Integrations({ onNavigate }) {
|
|
||||||
const [selectedIntegration, setSelectedIntegration] = useState(null)
|
|
||||||
|
|
||||||
const integrations = [
|
|
||||||
{ id: 'todoist', name: 'TODOist' },
|
|
||||||
{ id: 'telegram', name: 'Telegram' },
|
|
||||||
]
|
|
||||||
|
|
||||||
if (selectedIntegration) {
|
|
||||||
if (selectedIntegration === 'todoist') {
|
|
||||||
return <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
|
|
||||||
} else if (selectedIntegration === 'telegram') {
|
|
||||||
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 md:p-6">
|
|
||||||
<h1 className="text-2xl font-bold mb-6">Интеграции</h1>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{integrations.map((integration) => (
|
|
||||||
<button
|
|
||||||
key={integration.id}
|
|
||||||
onClick={() => setSelectedIntegration(integration.id)}
|
|
||||||
className="w-full p-4 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow text-left border border-gray-200 hover:border-indigo-300"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-lg font-semibold text-gray-800">
|
|
||||||
{integration.name}
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-gray-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Integrations
|
|
||||||
|
|
||||||
124
play-life-web/src/components/Profile.jsx
Normal file
124
play-life-web/src/components/Profile.jsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import TodoistIntegration from './TodoistIntegration'
|
||||||
|
import TelegramIntegration from './TelegramIntegration'
|
||||||
|
|
||||||
|
function Profile({ onNavigate }) {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [selectedIntegration, setSelectedIntegration] = useState(null)
|
||||||
|
|
||||||
|
const integrations = [
|
||||||
|
{ id: 'todoist', name: 'TODOist', icon: '✓' },
|
||||||
|
{ id: 'telegram', name: 'Telegram', icon: '✈️' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (window.confirm('Вы уверены, что хотите выйти?')) {
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIntegration) {
|
||||||
|
if (selectedIntegration === 'todoist') {
|
||||||
|
return <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
|
||||||
|
} else if (selectedIntegration === 'telegram') {
|
||||||
|
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 max-w-2xl mx-auto">
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 mb-6 text-white shadow-lg">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center text-2xl font-bold backdrop-blur-sm">
|
||||||
|
{user?.name ? user.name.charAt(0).toUpperCase() : user?.email?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-xl font-bold">
|
||||||
|
{user?.name || 'Пользователь'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-indigo-100 text-sm">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Integrations Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{integrations.map((integration) => (
|
||||||
|
<button
|
||||||
|
key={integration.id}
|
||||||
|
onClick={() => setSelectedIntegration(integration.id)}
|
||||||
|
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<span className="text-2xl">{integration.icon}</span>
|
||||||
|
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
|
||||||
|
{integration.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Section */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Аккаунт</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-red-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<span className="text-2xl">🚪</span>
|
||||||
|
<span className="text-gray-800 font-medium group-hover:text-red-600 transition-colors">
|
||||||
|
Выйти из аккаунта
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-400 group-hover:text-red-500 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version Info */}
|
||||||
|
<div className="mt-8 text-center text-gray-400 text-sm">
|
||||||
|
<p>Play Life</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
|
||||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||||
const PROJECTS_API_URL = '/projects'
|
const PROJECTS_API_URL = '/projects'
|
||||||
@@ -46,7 +47,7 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = project.id ?? project.name
|
const projectId = project.id ?? project.name
|
||||||
const response = await fetch(PROJECT_MOVE_API_URL, {
|
const response = await authFetch(PROJECT_MOVE_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -263,6 +264,7 @@ function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [projectsLoading, setProjectsLoading] = useState(false)
|
const [projectsLoading, setProjectsLoading] = useState(false)
|
||||||
const [projectsError, setProjectsError] = useState(null)
|
const [projectsError, setProjectsError] = useState(null)
|
||||||
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
||||||
@@ -381,7 +383,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
}
|
}
|
||||||
setProjectsError(null)
|
setProjectsError(null)
|
||||||
|
|
||||||
const response = await fetch(PROJECTS_API_URL)
|
const response = await authFetch(PROJECTS_API_URL)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Не удалось загрузить проекты')
|
throw new Error('Не удалось загрузить проекты')
|
||||||
}
|
}
|
||||||
@@ -483,7 +485,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
const sendPriorityChanges = useCallback(async (changes) => {
|
const sendPriorityChanges = useCallback(async (changes) => {
|
||||||
if (!changes.length) return
|
if (!changes.length) return
|
||||||
try {
|
try {
|
||||||
await fetch(PRIORITY_UPDATE_API_URL, {
|
await authFetch(PRIORITY_UPDATE_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(changes),
|
body: JSON.stringify(changes),
|
||||||
@@ -723,7 +725,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = selectedProject.id ?? selectedProject.name
|
const projectId = selectedProject.id ?? selectedProject.name
|
||||||
const response = await fetch(`/project/delete`, {
|
const response = await authFetch(`/project/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: projectId }),
|
body: JSON.stringify({ id: projectId }),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './Integrations.css'
|
import './Integrations.css'
|
||||||
|
|
||||||
function TelegramIntegration({ onBack }) {
|
function TelegramIntegration({ onBack }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [botToken, setBotToken] = useState('')
|
const [botToken, setBotToken] = useState('')
|
||||||
const [chatId, setChatId] = useState('')
|
const [chatId, setChatId] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -16,7 +18,7 @@ function TelegramIntegration({ onBack }) {
|
|||||||
const fetchIntegration = async () => {
|
const fetchIntegration = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fetch('/api/integrations/telegram')
|
const response = await authFetch('/api/integrations/telegram')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке интеграции')
|
throw new Error('Ошибка при загрузке интеграции')
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ function TelegramIntegration({ onBack }) {
|
|||||||
setError('')
|
setError('')
|
||||||
setSuccess('')
|
setSuccess('')
|
||||||
|
|
||||||
const response = await fetch('/api/integrations/telegram', {
|
const response = await authFetch('/api/integrations/telegram', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './TestConfigSelection.css'
|
import './TestConfigSelection.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [configs, setConfigs] = useState([])
|
const [configs, setConfigs] = useState([])
|
||||||
const [dictionaries, setDictionaries] = useState([])
|
const [dictionaries, setDictionaries] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -38,7 +40,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
|
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке конфигураций и словарей')
|
throw new Error('Ошибка при загрузке конфигураций и словарей')
|
||||||
}
|
}
|
||||||
@@ -92,7 +94,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
if (!selectedDictionary) return
|
if (!selectedDictionary) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
|
const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -119,7 +121,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
if (!selectedConfig) return
|
if (!selectedConfig) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/configs/${selectedConfig.id}`, {
|
const response = await authFetch(`${API_URL}/configs/${selectedConfig.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './TestWords.css'
|
import './TestWords.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
@@ -6,6 +7,7 @@ const API_URL = '/api'
|
|||||||
const DEFAULT_TEST_WORD_COUNT = 10
|
const DEFAULT_TEST_WORD_COUNT = 10
|
||||||
|
|
||||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||||
const configId = initialConfigId || null
|
const configId = initialConfigId || null
|
||||||
const maxCards = initialMaxCards || null
|
const maxCards = initialMaxCards || null
|
||||||
@@ -49,7 +51,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
throw new Error('config_id обязателен для запуска теста')
|
throw new Error('config_id обязателен для запуска теста')
|
||||||
}
|
}
|
||||||
const url = `${API_URL}/test/words?config_id=${configId}`
|
const url = `${API_URL}/test/words?config_id=${configId}`
|
||||||
const response = await fetch(url)
|
const response = await authFetch(url)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке слов')
|
throw new Error('Ошибка при загрузке слов')
|
||||||
}
|
}
|
||||||
@@ -176,7 +178,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
requestBody
|
requestBody
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/test/progress`, {
|
const response = await authFetch(`${API_URL}/test/progress`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './Integrations.css'
|
import './Integrations.css'
|
||||||
|
|
||||||
function TodoistIntegration({ onBack }) {
|
function TodoistIntegration({ onBack }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [webhookURL, setWebhookURL] = useState('')
|
const [webhookURL, setWebhookURL] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
@@ -13,7 +15,7 @@ function TodoistIntegration({ onBack }) {
|
|||||||
const fetchWebhookURL = async () => {
|
const fetchWebhookURL = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fetch('/api/integrations/todoist/webhook-url')
|
const response = await authFetch('/api/integrations/todoist/webhook-url')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке URL webhook')
|
throw new Error('Ошибка при загрузке URL webhook')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './WordList.css'
|
import './WordList.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
|
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [words, setWords] = useState([])
|
const [words, setWords] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -44,7 +46,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
|
|
||||||
const fetchDictionary = async (dictId) => {
|
const fetchDictionary = async (dictId) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/dictionaries`)
|
const response = await authFetch(`${API_URL}/dictionaries`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке словарей')
|
throw new Error('Ошибка при загрузке словарей')
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const url = `${API_URL}/words?dictionary_id=${dictId}`
|
const url = `${API_URL}/words?dictionary_id=${dictId}`
|
||||||
const response = await fetch(url)
|
const response = await authFetch(url)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке слов')
|
throw new Error('Ошибка при загрузке слов')
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
try {
|
try {
|
||||||
if (!hasValidDictionary(currentDictionaryId)) {
|
if (!hasValidDictionary(currentDictionaryId)) {
|
||||||
// Create new dictionary
|
// Create new dictionary
|
||||||
const response = await fetch(`${API_URL}/dictionaries`, {
|
const response = await authFetch(`${API_URL}/dictionaries`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -131,7 +133,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
onNavigate?.('words', { dictionaryId: newDictionaryId })
|
onNavigate?.('words', { dictionaryId: newDictionaryId })
|
||||||
} else if (hasValidDictionary(currentDictionaryId)) {
|
} else if (hasValidDictionary(currentDictionaryId)) {
|
||||||
// Update existing dictionary (rename)
|
// Update existing dictionary (rename)
|
||||||
const response = await fetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
230
play-life-web/src/components/auth/AuthContext.jsx
Normal file
230
play-life-web/src/components/auth/AuthContext.jsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'access_token'
|
||||||
|
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||||
|
const USER_KEY = 'user'
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
// Initialize from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
const savedUser = localStorage.getItem(USER_KEY)
|
||||||
|
|
||||||
|
if (token && savedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(savedUser))
|
||||||
|
// Verify token is still valid
|
||||||
|
const response = await fetch('/api/auth/me', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setUser(data.user)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
// Try to refresh token
|
||||||
|
const refreshed = await refreshToken()
|
||||||
|
if (!refreshed) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Auth init error:', err)
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
initAuth()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (email, password) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Ошибка входа')
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||||
|
setUser(data.user)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const register = useCallback(async (email, password, name) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password, name: name || undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Ошибка регистрации')
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||||
|
setUser(data.user)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
setUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshToken = useCallback(async () => {
|
||||||
|
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||||
|
|
||||||
|
if (!refresh) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refresh_token: refresh })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||||
|
setUser(data.user)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Refresh token error:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getToken = useCallback(() => {
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fetch wrapper that handles auth
|
||||||
|
const authFetch = useCallback(async (url, options = {}) => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await fetch(url, { ...options, headers })
|
||||||
|
|
||||||
|
// If 401, try to refresh token and retry
|
||||||
|
if (response.status === 401) {
|
||||||
|
const refreshed = await refreshToken()
|
||||||
|
if (refreshed) {
|
||||||
|
const newToken = localStorage.getItem(TOKEN_KEY)
|
||||||
|
headers['Authorization'] = `Bearer ${newToken}`
|
||||||
|
response = await fetch(url, { ...options, headers })
|
||||||
|
} else {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}, [refreshToken, logout])
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
getToken,
|
||||||
|
authFetch,
|
||||||
|
isAuthenticated: !!user
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthContext
|
||||||
|
|
||||||
16
play-life-web/src/components/auth/AuthScreen.jsx
Normal file
16
play-life-web/src/components/auth/AuthScreen.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import LoginForm from './LoginForm'
|
||||||
|
import RegisterForm from './RegisterForm'
|
||||||
|
|
||||||
|
function AuthScreen() {
|
||||||
|
const [mode, setMode] = useState('login') // 'login' or 'register'
|
||||||
|
|
||||||
|
if (mode === 'register') {
|
||||||
|
return <RegisterForm onSwitchToLogin={() => setMode('login')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoginForm onSwitchToRegister={() => setMode('register')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthScreen
|
||||||
|
|
||||||
112
play-life-web/src/components/auth/LoginForm.jsx
Normal file
112
play-life-web/src/components/auth/LoginForm.jsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
function LoginForm({ onSwitchToRegister }) {
|
||||||
|
const { login, error } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [localError, setLocalError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLocalError('')
|
||||||
|
|
||||||
|
if (!email.trim()) {
|
||||||
|
setLocalError('Введите email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
setLocalError('Введите пароль')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const success = await login(email, password)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setLocalError(error || 'Ошибка входа')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Play Life</h1>
|
||||||
|
<p className="text-gray-300">Войдите в свой аккаунт</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(localError || error) && (
|
||||||
|
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
|
||||||
|
{localError || error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Вход...
|
||||||
|
</span>
|
||||||
|
) : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Нет аккаунта?{' '}
|
||||||
|
<button
|
||||||
|
onClick={onSwitchToRegister}
|
||||||
|
className="text-purple-400 hover:text-purple-300 font-medium transition"
|
||||||
|
>
|
||||||
|
Зарегистрироваться
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginForm
|
||||||
|
|
||||||
150
play-life-web/src/components/auth/RegisterForm.jsx
Normal file
150
play-life-web/src/components/auth/RegisterForm.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
function RegisterForm({ onSwitchToLogin }) {
|
||||||
|
const { register, error } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [localError, setLocalError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLocalError('')
|
||||||
|
|
||||||
|
if (!email.trim()) {
|
||||||
|
setLocalError('Введите email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
setLocalError('Введите пароль')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setLocalError('Пароль должен быть не менее 6 символов')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setLocalError('Пароли не совпадают')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const success = await register(email, password, name || undefined)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setLocalError(error || 'Ошибка регистрации')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Play Life</h1>
|
||||||
|
<p className="text-gray-300">Создайте аккаунт</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Имя <span className="text-gray-500">(необязательно)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="Ваше имя"
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="Минимум 6 символов"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Подтвердите пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="Повторите пароль"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(localError || error) && (
|
||||||
|
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
|
||||||
|
{localError || error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Регистрация...
|
||||||
|
</span>
|
||||||
|
) : 'Зарегистрироваться'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Уже есть аккаунт?{' '}
|
||||||
|
<button
|
||||||
|
onClick={onSwitchToLogin}
|
||||||
|
className="text-purple-400 hover:text-purple-300 font-medium transition"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterForm
|
||||||
|
|
||||||
@@ -100,15 +100,24 @@ echo " База: $DB_NAME"
|
|||||||
echo " Хост: $DB_HOST:$DB_PORT"
|
echo " Хост: $DB_HOST:$DB_PORT"
|
||||||
echo " Файл: $FULL_DUMP_PATH"
|
echo " Файл: $FULL_DUMP_PATH"
|
||||||
|
|
||||||
# Распаковываем, если сжат
|
# Распаковываем и модифицируем дамп
|
||||||
TEMP_DUMP="/tmp/restore_$$.sql"
|
TEMP_DUMP="/tmp/restore_$$.sql"
|
||||||
if [[ "$FULL_DUMP_PATH" == *.gz ]]; then
|
if [[ "$FULL_DUMP_PATH" == *.gz ]]; then
|
||||||
echo " Распаковка дампа..."
|
echo " Распаковка и модификация дампа..."
|
||||||
gunzip -c "$FULL_DUMP_PATH" > "$TEMP_DUMP"
|
gunzip -c "$FULL_DUMP_PATH" | \
|
||||||
|
sed 's/n8n_user/'"$DB_USER"'/g' | \
|
||||||
|
sed '/^\\restrict/d' | \
|
||||||
|
sed '/^\\unrestrict/d' > "$TEMP_DUMP"
|
||||||
else
|
else
|
||||||
cp "$FULL_DUMP_PATH" "$TEMP_DUMP"
|
echo " Модификация дампа..."
|
||||||
|
cat "$FULL_DUMP_PATH" | \
|
||||||
|
sed 's/n8n_user/'"$DB_USER"'/g' | \
|
||||||
|
sed '/^\\restrict/d' | \
|
||||||
|
sed '/^\\unrestrict/d' > "$TEMP_DUMP"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo " Владелец таблиц в дампе заменён на: $DB_USER"
|
||||||
|
|
||||||
# Восстанавливаем через docker-compose, если контейнер запущен
|
# Восстанавливаем через docker-compose, если контейнер запущен
|
||||||
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
||||||
echo " Используется docker-compose..."
|
echo " Используется docker-compose..."
|
||||||
@@ -132,5 +141,33 @@ fi
|
|||||||
# Удаляем временный файл
|
# Удаляем временный файл
|
||||||
rm -f "$TEMP_DUMP"
|
rm -f "$TEMP_DUMP"
|
||||||
|
|
||||||
echo "✅ База данных успешно восстановлена из дампа!"
|
echo ""
|
||||||
|
echo "📦 Применение миграций для добавления user_id..."
|
||||||
|
|
||||||
|
# Определяем путь к миграциям
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MIGRATIONS_DIR="$SCRIPT_DIR/play-life-backend/migrations"
|
||||||
|
|
||||||
|
if [ -d "$MIGRATIONS_DIR" ]; then
|
||||||
|
# Применяем миграцию 009 для добавления user_id
|
||||||
|
MIGRATION_FILE="$MIGRATIONS_DIR/009_add_users_and_multitenancy.sql"
|
||||||
|
if [ -f "$MIGRATION_FILE" ]; then
|
||||||
|
echo " Применяем миграцию: 009_add_users_and_multitenancy.sql"
|
||||||
|
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
||||||
|
docker-compose exec -T db psql -U "$DB_USER" -d "$DB_NAME" < "$MIGRATION_FILE" 2>/dev/null || true
|
||||||
|
elif command -v psql &> /dev/null; then
|
||||||
|
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < "$MIGRATION_FILE" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo " ✅ Миграция применена"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ⚠️ Директория миграций не найдена: $MIGRATIONS_DIR"
|
||||||
|
echo " Миграции будут применены при запуске бэкенда"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ База данных успешно восстановлена из дампа!"
|
||||||
|
echo ""
|
||||||
|
echo "📌 ВАЖНО: После первой регистрации/входа пользователя все данные"
|
||||||
|
echo " будут автоматически привязаны к этому пользователю."
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user