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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "1.1.1",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -7,13 +7,30 @@ import AddWords from './components/AddWords'
|
||||
import TestConfigSelection from './components/TestConfigSelection'
|
||||
import AddConfig from './components/AddConfig'
|
||||
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)
|
||||
const CURRENT_WEEK_API_URL = '/playlife-feed'
|
||||
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 [selectedProject, setSelectedProject] = useState(null)
|
||||
const [loadedTabs, setLoadedTabs] = useState({
|
||||
@@ -25,7 +42,7 @@ function App() {
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
integrations: false,
|
||||
profile: false,
|
||||
})
|
||||
|
||||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||
@@ -38,7 +55,7 @@ function App() {
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
integrations: false,
|
||||
profile: false,
|
||||
})
|
||||
|
||||
// Параметры для навигации между вкладками
|
||||
@@ -77,7 +94,7 @@ function App() {
|
||||
|
||||
try {
|
||||
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)) {
|
||||
setActiveTab(savedTab)
|
||||
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
||||
@@ -104,7 +121,7 @@ function App() {
|
||||
}
|
||||
setCurrentWeekError(null)
|
||||
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) {
|
||||
throw new Error('Ошибка загрузки данных')
|
||||
}
|
||||
@@ -149,7 +166,7 @@ function App() {
|
||||
setCurrentWeekLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [authFetch])
|
||||
|
||||
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
|
||||
try {
|
||||
@@ -159,7 +176,7 @@ function App() {
|
||||
setFullStatisticsLoading(true)
|
||||
}
|
||||
setFullStatisticsError(null)
|
||||
const response = await fetch(FULL_STATISTICS_API_URL)
|
||||
const response = await authFetch(FULL_STATISTICS_API_URL)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки данных')
|
||||
}
|
||||
@@ -175,7 +192,7 @@ function App() {
|
||||
setFullStatisticsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [authFetch])
|
||||
|
||||
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
||||
const tabsInitializedRef = useRef({
|
||||
@@ -187,7 +204,7 @@ function App() {
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
integrations: false,
|
||||
profile: false,
|
||||
})
|
||||
|
||||
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
||||
@@ -476,9 +493,9 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.integrations && (
|
||||
<div className={activeTab === 'integrations' ? 'block' : 'hidden'}>
|
||||
<Integrations onNavigate={handleNavigate} />
|
||||
{loadedTabs.profile && (
|
||||
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
|
||||
<Profile onNavigate={handleNavigate} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -530,22 +547,21 @@ function App() {
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('integrations')}
|
||||
onClick={() => handleTabChange('profile')}
|
||||
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-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||
}`}
|
||||
title="Интеграции"
|
||||
title="Профиль"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</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>
|
||||
)}
|
||||
</button>
|
||||
@@ -556,6 +572,14 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './AddConfig.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [tryMessage, setTryMessage] = useState('')
|
||||
const [wordsCount, setWordsCount] = useState('10')
|
||||
@@ -19,7 +21,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
||||
const loadDictionaries = async () => {
|
||||
setLoadingDictionaries(true)
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке словарей')
|
||||
}
|
||||
@@ -39,7 +41,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
||||
const loadSelectedDictionaries = async () => {
|
||||
if (initialEditingConfig?.id) {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
|
||||
const response = await authFetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
|
||||
@@ -100,7 +102,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
||||
: `${API_URL}/configs`
|
||||
const method = initialEditingConfig ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await authFetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './AddWords.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [markdownText, setMarkdownText] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -81,7 +83,7 @@ function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
||||
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
|
||||
}))
|
||||
|
||||
const response = await fetch(`${API_URL}/words`, {
|
||||
const response = await authFetch(`${API_URL}/words`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
|
||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||
const PROJECTS_API_URL = '/projects'
|
||||
@@ -46,7 +47,7 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
||||
|
||||
try {
|
||||
const projectId = project.id ?? project.name
|
||||
const response = await fetch(PROJECT_MOVE_API_URL, {
|
||||
const response = await authFetch(PROJECT_MOVE_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -263,6 +264,7 @@ function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = nu
|
||||
}
|
||||
|
||||
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [projectsLoading, setProjectsLoading] = useState(false)
|
||||
const [projectsError, setProjectsError] = useState(null)
|
||||
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
||||
@@ -381,7 +383,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
}
|
||||
setProjectsError(null)
|
||||
|
||||
const response = await fetch(PROJECTS_API_URL)
|
||||
const response = await authFetch(PROJECTS_API_URL)
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить проекты')
|
||||
}
|
||||
@@ -483,7 +485,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
const sendPriorityChanges = useCallback(async (changes) => {
|
||||
if (!changes.length) return
|
||||
try {
|
||||
await fetch(PRIORITY_UPDATE_API_URL, {
|
||||
await authFetch(PRIORITY_UPDATE_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(changes),
|
||||
@@ -723,7 +725,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
|
||||
try {
|
||||
const projectId = selectedProject.id ?? selectedProject.name
|
||||
const response = await fetch(`/project/delete`, {
|
||||
const response = await authFetch(`/project/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: projectId }),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './Integrations.css'
|
||||
|
||||
function TelegramIntegration({ onBack }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [botToken, setBotToken] = useState('')
|
||||
const [chatId, setChatId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -16,7 +18,7 @@ function TelegramIntegration({ onBack }) {
|
||||
const fetchIntegration = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/integrations/telegram')
|
||||
const response = await authFetch('/api/integrations/telegram')
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке интеграции')
|
||||
}
|
||||
@@ -42,7 +44,7 @@ function TelegramIntegration({ onBack }) {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
const response = await fetch('/api/integrations/telegram', {
|
||||
const response = await authFetch('/api/integrations/telegram', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './TestConfigSelection.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [configs, setConfigs] = useState([])
|
||||
const [dictionaries, setDictionaries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -38,7 +40,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||
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) {
|
||||
throw new Error('Ошибка при загрузке конфигураций и словарей')
|
||||
}
|
||||
@@ -92,7 +94,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||
if (!selectedDictionary) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
|
||||
const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -119,7 +121,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||
if (!selectedConfig) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/configs/${selectedConfig.id}`, {
|
||||
const response = await authFetch(`${API_URL}/configs/${selectedConfig.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './TestWords.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
@@ -6,6 +7,7 @@ const API_URL = '/api'
|
||||
const DEFAULT_TEST_WORD_COUNT = 10
|
||||
|
||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
||||
const { authFetch } = useAuth()
|
||||
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||
const configId = initialConfigId || null
|
||||
const maxCards = initialMaxCards || null
|
||||
@@ -49,7 +51,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
throw new Error('config_id обязателен для запуска теста')
|
||||
}
|
||||
const url = `${API_URL}/test/words?config_id=${configId}`
|
||||
const response = await fetch(url)
|
||||
const response = await authFetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке слов')
|
||||
}
|
||||
@@ -176,7 +178,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
||||
requestBody
|
||||
})
|
||||
|
||||
const response = await fetch(`${API_URL}/test/progress`, {
|
||||
const response = await authFetch(`${API_URL}/test/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './Integrations.css'
|
||||
|
||||
function TodoistIntegration({ onBack }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [webhookURL, setWebhookURL] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -13,7 +15,7 @@ function TodoistIntegration({ onBack }) {
|
||||
const fetchWebhookURL = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/integrations/todoist/webhook-url')
|
||||
const response = await authFetch('/api/integrations/todoist/webhook-url')
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке URL webhook')
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './WordList.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [words, setWords] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -44,7 +46,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
||||
|
||||
const fetchDictionary = async (dictId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/dictionaries`)
|
||||
const response = await authFetch(`${API_URL}/dictionaries`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке словарей')
|
||||
}
|
||||
@@ -74,7 +76,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
||||
try {
|
||||
setLoading(true)
|
||||
const url = `${API_URL}/words?dictionary_id=${dictId}`
|
||||
const response = await fetch(url)
|
||||
const response = await authFetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке слов')
|
||||
}
|
||||
@@ -102,7 +104,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
||||
try {
|
||||
if (!hasValidDictionary(currentDictionaryId)) {
|
||||
// Create new dictionary
|
||||
const response = await fetch(`${API_URL}/dictionaries`, {
|
||||
const response = await authFetch(`${API_URL}/dictionaries`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -131,7 +133,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
||||
onNavigate?.('words', { dictionaryId: newDictionaryId })
|
||||
} else if (hasValidDictionary(currentDictionaryId)) {
|
||||
// Update existing dictionary (rename)
|
||||
const response = await fetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
||||
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'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
|
||||
|
||||
Reference in New Issue
Block a user