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

@@ -3,12 +3,14 @@ import { useState, useEffect, createContext, lazy, Suspense } from 'react'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LanguageProvider } from './i18n/LanguageContext'
import { ToastProvider } from './components/ToastContainer'
import { ThemeProvider } from './contexts/ThemeContext'
import ErrorBoundary from './components/ErrorBoundary'
import Layout from './components/Layout'
import Tutorial from './components/Tutorial'
import Modal from './components/Modal'
import { api } from './utils/api'
import { useLanguage } from './i18n/LanguageContext'
import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShortcuts'
// Lazy-loaded page components
const Dashboard = lazy(() => import('./pages/Dashboard'))
@@ -32,6 +34,8 @@ const PublicReview = lazy(() => import('./pages/PublicReview'))
const Issues = lazy(() => import('./pages/Issues'))
const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
const TEAM_ROLES = [
{ value: 'manager', label: 'Manager' },
@@ -62,6 +66,9 @@ function AppContent() {
const [profileForm, setProfileForm] = useState({ name: '', team_role: '', phone: '', brands: '' })
const [profileSaving, setProfileSaving] = useState(false)
// Keyboard shortcuts
useKeyboardShortcuts(DEFAULT_SHORTCUTS)
useEffect(() => {
if (user && !authLoading) {
loadInitialData()
@@ -277,6 +284,8 @@ function AppContent() {
<Suspense fallback={<div className="min-h-screen bg-surface-secondary flex items-center justify-center"><div className="animate-pulse text-text-tertiary">Loading...</div></div>}>
<Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
<Route path="/reset-password" element={user ? <Navigate to="/" replace /> : <ResetPassword />} />
<Route path="/review/:token" element={<PublicReview />} />
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
<Route path="/track/:token" element={<PublicIssueTracker />} />
@@ -320,7 +329,9 @@ function App() {
<LanguageProvider>
<AuthProvider>
<ToastProvider>
<AppContent />
<ThemeProvider>
<AppContent />
</ThemeProvider>
</ToastProvider>
</AuthProvider>
</LanguageProvider>

View File

@@ -310,6 +310,25 @@
"login.subtitle": "سجل دخولك للمتابعة",
"login.forgotPassword": "نسيت كلمة المرور؟",
"login.defaultCreds": "بيانات الدخول الافتراضية:",
"forgotPassword.title": "نسيت كلمة المرور",
"forgotPassword.subtitle": "أدخل بريدك الإلكتروني لتلقي رابط إعادة التعيين",
"forgotPassword.emailPlaceholder": "بريدك@email.com",
"forgotPassword.submit": "إرسال رابط إعادة التعيين",
"forgotPassword.sending": "جارٍ الإرسال...",
"forgotPassword.success": "إذا كان هناك حساب بهذا البريد الإلكتروني، فقد تم إرسال رابط إعادة التعيين.",
"forgotPassword.backToLogin": "العودة لتسجيل الدخول",
"forgotPassword.error": "حدث خطأ. يرجى المحاولة مرة أخرى.",
"resetPassword.title": "إعادة تعيين كلمة المرور",
"resetPassword.subtitle": "أدخل كلمة المرور الجديدة",
"resetPassword.newPassword": "كلمة المرور الجديدة",
"resetPassword.confirmPassword": "تأكيد كلمة المرور",
"resetPassword.submit": "إعادة تعيين كلمة المرور",
"resetPassword.resetting": "جارٍ إعادة التعيين...",
"resetPassword.success": "تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.",
"resetPassword.invalidToken": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية.",
"resetPassword.goToLogin": "الذهاب لتسجيل الدخول",
"resetPassword.passwordMismatch": "كلمتا المرور غير متطابقتين",
"resetPassword.error": "فشل إعادة تعيين كلمة المرور. ربما انتهت صلاحية الرابط.",
"comments.title": "النقاش",
"comments.noComments": "لا توجد تعليقات بعد. ابدأ المحادثة.",
"comments.placeholder": "اكتب تعليقاً...",

View File

@@ -310,6 +310,25 @@
"login.subtitle": "Sign in to continue",
"login.forgotPassword": "Forgot password?",
"login.defaultCreds": "Default credentials:",
"forgotPassword.title": "Forgot Password",
"forgotPassword.subtitle": "Enter your email to receive a reset link",
"forgotPassword.emailPlaceholder": "your@email.com",
"forgotPassword.submit": "Send Reset Link",
"forgotPassword.sending": "Sending...",
"forgotPassword.success": "If an account with that email exists, a reset link has been sent.",
"forgotPassword.backToLogin": "Back to Login",
"forgotPassword.error": "Something went wrong. Please try again.",
"resetPassword.title": "Reset Password",
"resetPassword.subtitle": "Enter your new password",
"resetPassword.newPassword": "New Password",
"resetPassword.confirmPassword": "Confirm Password",
"resetPassword.submit": "Reset Password",
"resetPassword.resetting": "Resetting...",
"resetPassword.success": "Password has been reset. You can now log in.",
"resetPassword.invalidToken": "Invalid or expired reset link.",
"resetPassword.goToLogin": "Go to Login",
"resetPassword.passwordMismatch": "Passwords do not match",
"resetPassword.error": "Failed to reset password. The link may have expired.",
"comments.title": "Discussion",
"comments.noComments": "No comments yet. Start the conversation.",
"comments.placeholder": "Write a comment...",

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>
)
}