feat: consolidate auth into NocoDB, add password reset, health check
Some checks failed
Deploy / deploy (push) Failing after 9s
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:
@@ -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>
|
||||
|
||||
@@ -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": "اكتب تعليقاً...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
111
client/src/pages/ForgotPassword.jsx
Normal file
111
client/src/pages/ForgotPassword.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
141
client/src/pages/ResetPassword.jsx
Normal file
141
client/src/pages/ResetPassword.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user