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:
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