feat: consolidate auth into NocoDB, add password reset, health check
Some checks failed
Deploy / deploy (push) Failing after 9s

- Migrate auth credentials from SQLite (auth.db) to NocoDB Users table
  with one-time migration function (auth.db → auth.db.bak)
- Add email-based password reset via Cloudron SMTP (nodemailer)
- Add GET /api/health endpoint for monitoring
- Add startup env var validation with clear error messages
- Strip sensitive fields (password_hash, reset_token) from all API responses
- Add ForgotPassword + ResetPassword pages with i18n (en/ar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-04 11:47:27 +03:00
parent 42a5f17d0b
commit c31e6222d7
12 changed files with 670 additions and 58 deletions

View File

@@ -0,0 +1,111 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Mail, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api'
export default function ForgotPassword() {
const { t } = useLanguage()
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [sent, setSent] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.post('/auth/forgot-password', { email })
setSent(true)
} catch (err) {
setError(err.message || t('forgotPassword.error'))
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('forgotPassword.title')}</h1>
<p className="text-slate-400">{t('forgotPassword.subtitle')}</p>
</div>
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
{sent ? (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
<p className="text-slate-300 text-sm">{t('forgotPassword.success')}</p>
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('forgotPassword.backToLogin')}
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('auth.email')}</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder={t('forgotPassword.emailPlaceholder')}
required
autoFocus
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('forgotPassword.sending')}
</span>
) : (
t('forgotPassword.submit')
)}
</button>
<div className="text-center">
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-slate-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('forgotPassword.backToLogin')}
</Link>
</div>
</form>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
@@ -259,7 +259,9 @@ export default function Login() {
{!needsSetup && (
<div className="mt-6 pt-6 border-t border-slate-700/50">
<p className="text-xs text-slate-500 text-center">
{t('login.forgotPassword')}
<Link to="/forgot-password" className="hover:text-slate-300 transition-colors underline">
{t('login.forgotPassword')}
</Link>
</p>
</div>
)}

View File

@@ -0,0 +1,141 @@
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, AlertCircle, CheckCircle, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api'
export default function ResetPassword() {
const { t } = useLanguage()
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
if (!token) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md text-center">
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<p className="text-slate-300 mb-4">{t('resetPassword.invalidToken')}</p>
<Link to="/login" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
{t('resetPassword.goToLogin')}
</Link>
</div>
</div>
</div>
)
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (password !== confirm) {
setError(t('resetPassword.passwordMismatch'))
return
}
setLoading(true)
try {
await api.post('/auth/reset-password', { token, password })
setSuccess(true)
} catch (err) {
setError(err.message || t('resetPassword.error'))
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">{t('resetPassword.title')}</h1>
<p className="text-slate-400">{t('resetPassword.subtitle')}</p>
</div>
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
{success ? (
<div className="text-center space-y-4">
<div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
<p className="text-slate-300 text-sm">{t('resetPassword.success')}</p>
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('resetPassword.goToLogin')}
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.newPassword')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
minLength={6}
autoFocus
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{t('resetPassword.confirmPassword')}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
minLength={6}
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('resetPassword.resetting')}
</span>
) : (
t('resetPassword.submit')
)}
</button>
</form>
)}
</div>
</div>
</div>
)
}