v2.0.0: Multi-user authentication with JWT
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:
poignatov
2026-01-01 18:21:18 +03:00
parent 6015b62d29
commit 4a06ceb7f6
23 changed files with 1970 additions and 279 deletions

View 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

View 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

View 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

View 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