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>
|
||||
)
|
||||
}
|
||||
29
server/.env.example
Normal file
29
server/.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# Required
|
||||
NOCODB_URL=http://localhost:8090
|
||||
NOCODB_TOKEN=your-nocodb-api-token
|
||||
NOCODB_BASE_ID=your-base-id
|
||||
|
||||
# Session (required in production)
|
||||
SESSION_SECRET=your-random-secret-key
|
||||
NODE_ENV=development
|
||||
|
||||
# CORS (optional, restricts allowed origins)
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# App URL for email links (optional, auto-detected from request if not set)
|
||||
APP_URL=https://your-app.example.com
|
||||
|
||||
# SMTP for password reset emails
|
||||
# Cloudron injects these automatically when sendmail addon is enabled
|
||||
CLOUDRON_MAIL_SMTP_SERVER=
|
||||
CLOUDRON_MAIL_SMTP_PORT=587
|
||||
CLOUDRON_MAIL_SMTP_USERNAME=
|
||||
CLOUDRON_MAIL_SMTP_PASSWORD=
|
||||
CLOUDRON_MAIL_FROM=noreply@your-domain.com
|
||||
|
||||
# Alternative SMTP config (used if CLOUDRON_MAIL_* not set)
|
||||
# MAIL_SMTP_SERVER=smtp.example.com
|
||||
# MAIL_SMTP_PORT=587
|
||||
# MAIL_SMTP_USERNAME=
|
||||
# MAIL_SMTP_PASSWORD=
|
||||
# MAIL_FROM=noreply@your-domain.com
|
||||
@@ -87,6 +87,21 @@ function getUserModules(user, allModules) {
|
||||
return allModules;
|
||||
}
|
||||
|
||||
// Strip sensitive fields from user data before sending to client
|
||||
const SENSITIVE_USER_FIELDS = ['password_hash', 'reset_token', 'reset_token_expires'];
|
||||
function stripSensitiveFields(data) {
|
||||
if (Array.isArray(data)) return data.map(stripSensitiveFields);
|
||||
if (data && typeof data === 'object') {
|
||||
const out = { ...data };
|
||||
for (const f of SENSITIVE_USER_FIELDS) {
|
||||
delete out[f];
|
||||
delete out[f.replace(/_([a-z])/g, (_, c) => c.toUpperCase())];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRecordName,
|
||||
batchResolveNames,
|
||||
@@ -95,5 +110,6 @@ module.exports = {
|
||||
pickBodyFields,
|
||||
sanitizeWhereValue,
|
||||
getUserModules,
|
||||
stripSensitiveFields,
|
||||
_nameCache,
|
||||
};
|
||||
|
||||
37
server/mail.js
Normal file
37
server/mail.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
function getSmtpConfig() {
|
||||
const server = process.env.CLOUDRON_MAIL_SMTP_SERVER || process.env.MAIL_SMTP_SERVER;
|
||||
const port = process.env.CLOUDRON_MAIL_SMTP_PORT || process.env.MAIL_SMTP_PORT || '587';
|
||||
const username = process.env.CLOUDRON_MAIL_SMTP_USERNAME || process.env.MAIL_SMTP_USERNAME;
|
||||
const password = process.env.CLOUDRON_MAIL_SMTP_PASSWORD || process.env.MAIL_SMTP_PASSWORD;
|
||||
const from = process.env.CLOUDRON_MAIL_FROM || process.env.MAIL_FROM || username;
|
||||
|
||||
if (!server) return null;
|
||||
return { host: server, port: Number(port), secure: Number(port) === 465, auth: (username && password) ? { user: username, pass: password } : undefined, from };
|
||||
}
|
||||
|
||||
let _transporter = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (_transporter) return _transporter;
|
||||
const config = getSmtpConfig();
|
||||
if (!config) return null;
|
||||
_transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.auth,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
_transporter._from = config.from;
|
||||
return _transporter;
|
||||
}
|
||||
|
||||
async function sendMail({ to, subject, html, text }) {
|
||||
const transporter = getTransporter();
|
||||
if (!transporter) throw new Error('SMTP not configured');
|
||||
return transporter.sendMail({ from: transporter._from, to, subject, html, text });
|
||||
}
|
||||
|
||||
module.exports = { sendMail, getSmtpConfig };
|
||||
12
server/package-lock.json
generated
12
server/package-lock.json
generated
@@ -14,7 +14,8 @@
|
||||
"dotenv": "^17.2.4",
|
||||
"express": "^4.21.0",
|
||||
"express-session": "^1.19.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^8.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.6.2"
|
||||
@@ -1704,6 +1705,15 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"dotenv": "^17.2.4",
|
||||
"express": "^4.21.0",
|
||||
"express-session": "^1.19.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^8.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.6.2"
|
||||
|
||||
322
server/server.js
322
server/server.js
@@ -9,9 +9,9 @@ const bcrypt = require('bcrypt');
|
||||
const session = require('express-session');
|
||||
const SqliteStore = require('connect-sqlite3')(session);
|
||||
const nocodb = require('./nocodb');
|
||||
const { authDb } = require('./auth-db');
|
||||
const crypto = require('crypto');
|
||||
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
|
||||
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules } = require('./helpers');
|
||||
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields } = require('./helpers');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -442,9 +442,15 @@ async function ensureRequiredTables() {
|
||||
const TEXT_COLUMNS = {
|
||||
Projects: [{ name: 'thumbnail', uidt: 'SingleLineText' }, { name: 'color', uidt: 'SingleLineText' }],
|
||||
Tasks: [{ name: 'thumbnail', uidt: 'SingleLineText' }, { name: 'color', uidt: 'SingleLineText' }],
|
||||
Users: [{ name: 'modules', uidt: 'LongText' }],
|
||||
Users: [
|
||||
{ name: 'modules', uidt: 'LongText' },
|
||||
{ name: 'password_hash', uidt: 'SingleLineText' },
|
||||
{ name: 'reset_token', uidt: 'SingleLineText' },
|
||||
{ name: 'reset_token_expires', uidt: 'SingleLineText' },
|
||||
],
|
||||
BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
|
||||
Comments: [{ name: 'version_number', uidt: 'Number' }],
|
||||
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
|
||||
Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
|
||||
};
|
||||
|
||||
@@ -543,24 +549,61 @@ async function backfillFKs() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HEALTH CHECK ──────────────────────────────────────────────
|
||||
|
||||
app.get('/api/health', async (req, res) => {
|
||||
const checks = { server: true, nocodb: false, smtp: false };
|
||||
const errors = [];
|
||||
|
||||
try {
|
||||
await nocodb.resolveTableId('Users');
|
||||
checks.nocodb = true;
|
||||
} catch (err) {
|
||||
errors.push(`NocoDB: ${err.message}`);
|
||||
}
|
||||
|
||||
const { getSmtpConfig } = require('./mail');
|
||||
checks.smtp = !!getSmtpConfig();
|
||||
if (!checks.smtp) errors.push('SMTP: not configured');
|
||||
|
||||
const requiredEnvVars = ['NOCODB_URL', 'NOCODB_TOKEN', 'NOCODB_BASE_ID'];
|
||||
const missingEnv = requiredEnvVars.filter(v => !process.env[v]);
|
||||
if (missingEnv.length > 0) errors.push(`Missing env vars: ${missingEnv.join(', ')}`);
|
||||
|
||||
const healthy = checks.server && checks.nocodb && missingEnv.length === 0;
|
||||
res.status(healthy ? 200 : 503).json({
|
||||
status: healthy ? 'healthy' : 'degraded',
|
||||
checks,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SETUP ROUTES ───────────────────────────────────────────────
|
||||
|
||||
app.get('/api/setup/status', (req, res) => {
|
||||
const count = authDb.prepare('SELECT COUNT(*) as cnt FROM auth_credentials').get().cnt;
|
||||
res.json({ needsSetup: count === 0 });
|
||||
app.get('/api/setup/status', async (req, res) => {
|
||||
try {
|
||||
const users = await nocodb.list('Users', { limit: 1 });
|
||||
res.json({ needsSetup: users.length === 0 });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to check setup status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/setup', async (req, res) => {
|
||||
const count = authDb.prepare('SELECT COUNT(*) as cnt FROM auth_credentials').get().cnt;
|
||||
if (count > 0) return res.status(403).json({ error: 'Setup already completed' });
|
||||
try {
|
||||
const users = await nocodb.list('Users', { limit: 1 });
|
||||
if (users.length > 0) return res.status(403).json({ error: 'Setup already completed' });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'Failed to check setup status' });
|
||||
}
|
||||
|
||||
const { name, email, password } = req.body;
|
||||
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, and password are required' });
|
||||
|
||||
try {
|
||||
const created = await nocodb.create('Users', { name, email, role: 'superadmin' });
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
||||
const created = await nocodb.create('Users', { name, email, role: 'superadmin', password_hash: passwordHash });
|
||||
console.log(`[SETUP] Superadmin created: ${email} (NocoDB Id: ${created.Id})`);
|
||||
res.status(201).json({ message: 'Superadmin account created. You can now log in.' });
|
||||
} catch (err) {
|
||||
@@ -576,16 +619,13 @@ app.post('/api/auth/login', async (req, res) => {
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password are required' });
|
||||
|
||||
try {
|
||||
const cred = authDb.prepare('SELECT * FROM auth_credentials WHERE email = ?').get(email);
|
||||
if (!cred) return res.status(401).json({ error: 'Invalid email or password' });
|
||||
const users = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 });
|
||||
const user = users[0];
|
||||
if (!user || !user.password_hash) return res.status(401).json({ error: 'Invalid email or password' });
|
||||
|
||||
const valid = await bcrypt.compare(password, cred.password_hash);
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) return res.status(401).json({ error: 'Invalid email or password' });
|
||||
|
||||
// Fetch profile from NocoDB
|
||||
const user = await nocodb.get('Users', cred.nocodb_user_id);
|
||||
if (!user) return res.status(401).json({ error: 'User profile not found' });
|
||||
|
||||
req.session.userId = user.Id;
|
||||
req.session.userEmail = user.email;
|
||||
req.session.userRole = user.role;
|
||||
@@ -619,6 +659,70 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/forgot-password', async (req, res) => {
|
||||
const { email } = req.body;
|
||||
if (!email) return res.status(400).json({ error: 'Email is required' });
|
||||
|
||||
try {
|
||||
const users = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 });
|
||||
if (users.length > 0) {
|
||||
const user = users[0];
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const expires = new Date(Date.now() + 3600000).toISOString();
|
||||
|
||||
await nocodb.update('Users', user.Id, { reset_token: tokenHash, reset_token_expires: expires });
|
||||
|
||||
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
|
||||
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
|
||||
|
||||
const { sendMail } = require('./mail');
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Password Reset',
|
||||
html: `<div style="font-family:sans-serif;max-width:500px;margin:0 auto">
|
||||
<h2>Password Reset</h2>
|
||||
<p>Hello ${user.name || ''},</p>
|
||||
<p>Click below to reset your password:</p>
|
||||
<p style="text-align:center;margin:30px 0">
|
||||
<a href="${resetUrl}" style="background:#3b82f6;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold">Reset Password</a>
|
||||
</p>
|
||||
<p style="color:#666;font-size:14px">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
|
||||
</div>`,
|
||||
text: `Hello ${user.name || ''},\n\nVisit this link to reset your password:\n${resetUrl}\n\nExpires in 1 hour.`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Forgot password error:', err);
|
||||
}
|
||||
// Always return success to prevent email enumeration
|
||||
res.json({ message: 'If an account with that email exists, a reset link has been sent.' });
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset-password', async (req, res) => {
|
||||
const { token, password } = req.body;
|
||||
if (!token || !password) return res.status(400).json({ error: 'Token and password are required' });
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
|
||||
try {
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const users = await nocodb.list('Users', { where: `(reset_token,eq,${tokenHash})`, limit: 1 });
|
||||
if (users.length === 0) return res.status(400).json({ error: 'Invalid or expired reset token' });
|
||||
|
||||
const user = users[0];
|
||||
if (!user.reset_token_expires || new Date(user.reset_token_expires) < new Date()) {
|
||||
return res.status(400).json({ error: 'Invalid or expired reset token' });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await nocodb.update('Users', user.Id, { password_hash: hash, reset_token: '', reset_token_expires: '' });
|
||||
res.json({ message: 'Password has been reset. You can now log in.' });
|
||||
} catch (err) {
|
||||
console.error('Reset password error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset password' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await nocodb.get('Users', req.session.userId);
|
||||
@@ -699,14 +803,14 @@ app.patch('/api/users/me/password', requireAuth, async (req, res) => {
|
||||
if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
|
||||
|
||||
try {
|
||||
const cred = authDb.prepare('SELECT * FROM auth_credentials WHERE nocodb_user_id = ?').get(req.session.userId);
|
||||
if (!cred) return res.status(404).json({ error: 'Credentials not found' });
|
||||
const user = await nocodb.get('Users', req.session.userId);
|
||||
if (!user || !user.password_hash) return res.status(404).json({ error: 'Credentials not found' });
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, cred.password_hash);
|
||||
const valid = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
authDb.prepare('UPDATE auth_credentials SET password_hash = ? WHERE nocodb_user_id = ?').run(hash, req.session.userId);
|
||||
await nocodb.update('Users', req.session.userId, { password_hash: hash });
|
||||
res.json({ message: 'Password updated successfully' });
|
||||
} catch (err) {
|
||||
console.error('Change password error:', err);
|
||||
@@ -728,7 +832,7 @@ app.patch('/api/users/me/tutorial', requireAuth, async (req, res) => {
|
||||
app.get('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
try {
|
||||
const users = await nocodb.list('Users', { sort: '-CreatedAt' });
|
||||
res.json(users);
|
||||
res.json(stripSensitiveFields(users));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to load users' });
|
||||
}
|
||||
@@ -740,21 +844,21 @@ app.post('/api/users', requireAuth, requireRole('superadmin'), async (req, res)
|
||||
if (!['superadmin', 'manager', 'contributor'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
|
||||
try {
|
||||
const existing = authDb.prepare('SELECT id FROM auth_credentials WHERE email = ?').get(email);
|
||||
if (existing) return res.status(409).json({ error: 'Email already exists' });
|
||||
const existing = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 });
|
||||
if (existing.length > 0) return res.status(409).json({ error: 'Email already exists' });
|
||||
|
||||
const defaultPassword = password || 'changeme123';
|
||||
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
||||
const created = await nocodb.create('Users', {
|
||||
name, email, role, avatar: avatar || null,
|
||||
team_role: team_role || null,
|
||||
brands: JSON.stringify(brands || []),
|
||||
phone: phone || null,
|
||||
modules: JSON.stringify(modules || ALL_MODULES),
|
||||
password_hash: passwordHash,
|
||||
});
|
||||
const defaultPassword = password || 'changeme123';
|
||||
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
||||
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
||||
const user = await nocodb.get('Users', created.Id);
|
||||
res.status(201).json({ ...user, id: user.Id, _id: user.Id });
|
||||
res.status(201).json(stripSensitiveFields({ ...user, id: user.Id, _id: user.Id }));
|
||||
} catch (err) {
|
||||
console.error('Create user error:', err);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
@@ -776,16 +880,12 @@ app.patch('/api/users/:id', requireAuth, requireRole('superadmin'), async (req,
|
||||
if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules);
|
||||
|
||||
if (req.body.password) {
|
||||
const hash = await bcrypt.hash(req.body.password, 10);
|
||||
authDb.prepare('UPDATE auth_credentials SET password_hash = ? WHERE nocodb_user_id = ?').run(hash, Number(id));
|
||||
}
|
||||
if (req.body.email && req.body.email !== existing.email) {
|
||||
authDb.prepare('UPDATE auth_credentials SET email = ? WHERE nocodb_user_id = ?').run(req.body.email, Number(id));
|
||||
data.password_hash = await bcrypt.hash(req.body.password, 10);
|
||||
}
|
||||
|
||||
if (Object.keys(data).length > 0) await nocodb.update('Users', id, data);
|
||||
const user = await nocodb.get('Users', id);
|
||||
res.json(user);
|
||||
res.json(stripSensitiveFields(user));
|
||||
} catch (err) {
|
||||
console.error('Update user error:', err);
|
||||
res.status(500).json({ error: 'Failed to update user' });
|
||||
@@ -799,7 +899,6 @@ app.delete('/api/users/:id', requireAuth, requireRole('superadmin'), async (req,
|
||||
const user = await nocodb.get('Users', id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
await nocodb.delete('Users', id);
|
||||
authDb.prepare('DELETE FROM auth_credentials WHERE nocodb_user_id = ?').run(Number(id));
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
@@ -813,7 +912,7 @@ app.get('/api/users/assignable', requireAuth, async (req, res) => {
|
||||
const users = await nocodb.list('Users', {
|
||||
sort: 'name',
|
||||
});
|
||||
res.json(users.map(u => ({ ...u, id: u.Id, _id: u.Id })));
|
||||
res.json(stripSensitiveFields(users.map(u => ({ ...u, id: u.Id, _id: u.Id }))));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to load assignable users' });
|
||||
}
|
||||
@@ -852,11 +951,11 @@ app.get('/api/users/team', requireAuth, async (req, res) => {
|
||||
const teamMap = {};
|
||||
for (const t of allTeams) teamMap[t.Id] = t.name;
|
||||
|
||||
res.json(filtered.map(u => {
|
||||
res.json(stripSensitiveFields(filtered.map(u => {
|
||||
const userTeamEntries = allTeamMembers.filter(tm => tm.user_id === u.Id);
|
||||
const teams = userTeamEntries.map(tm => ({ id: tm.team_id, name: teamMap[tm.team_id] || 'Unknown' }));
|
||||
return { ...u, id: u.Id, _id: u.Id, teams };
|
||||
}));
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error('Team list error:', err);
|
||||
res.status(500).json({ error: 'Failed to load team' });
|
||||
@@ -874,21 +973,20 @@ app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), a
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = authDb.prepare('SELECT id FROM auth_credentials WHERE email = ?').get(email);
|
||||
if (existing) return res.status(409).json({ error: 'Email already exists' });
|
||||
const existing = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 });
|
||||
if (existing.length > 0) return res.status(409).json({ error: 'Email already exists' });
|
||||
|
||||
const defaultPassword = password || 'changeme123';
|
||||
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
||||
const created = await nocodb.create('Users', {
|
||||
name, email, role: userRole, team_role: team_role || null,
|
||||
brands: JSON.stringify(brands || []), phone: phone || null,
|
||||
modules: JSON.stringify(req.body.modules || ALL_MODULES),
|
||||
password_hash: passwordHash,
|
||||
});
|
||||
|
||||
const defaultPassword = password || 'changeme123';
|
||||
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
||||
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
||||
|
||||
const user = await nocodb.get('Users', created.Id);
|
||||
res.status(201).json({ ...user, id: user.Id, _id: user.Id });
|
||||
res.status(201).json(stripSensitiveFields({ ...user, id: user.Id, _id: user.Id }));
|
||||
} catch (err) {
|
||||
console.error('Create team member error:', err);
|
||||
res.status(500).json({ error: 'Failed to create team member' });
|
||||
@@ -909,13 +1007,9 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
|
||||
|
||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
if (data.email && data.email !== existing.email) {
|
||||
authDb.prepare('UPDATE auth_credentials SET email = ? WHERE nocodb_user_id = ?').run(data.email, Number(req.params.id));
|
||||
}
|
||||
|
||||
await nocodb.update('Users', req.params.id, data);
|
||||
const user = await nocodb.get('Users', req.params.id);
|
||||
res.json({ ...user, id: user.Id, _id: user.Id });
|
||||
res.json(stripSensitiveFields({ ...user, id: user.Id, _id: user.Id }));
|
||||
} catch (err) {
|
||||
console.error('Update team error:', err);
|
||||
res.status(500).json({ error: 'Failed to update team member' });
|
||||
@@ -927,7 +1021,6 @@ app.delete('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manage
|
||||
const user = await nocodb.get('Users', req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
await nocodb.delete('Users', req.params.id);
|
||||
authDb.prepare('DELETE FROM auth_credentials WHERE nocodb_user_id = ?').run(Number(req.params.id));
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to delete team member' });
|
||||
@@ -939,7 +1032,7 @@ app.delete('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manage
|
||||
app.get('/api/team', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const users = await nocodb.list('Users', { sort: 'name' });
|
||||
res.json(users.map(u => ({ ...u, id: u.Id, _id: u.Id })));
|
||||
res.json(stripSensitiveFields(users.map(u => ({ ...u, id: u.Id, _id: u.Id }))));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to load team' });
|
||||
}
|
||||
@@ -2222,7 +2315,7 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
|
||||
brand_name: proj.brand_id ? await getRecordName('Brands', proj.brand_id) : null,
|
||||
};
|
||||
}
|
||||
} catch (err) { console.error('Resolve project brand:', err.message); }
|
||||
} catch (err) { /* project may have been deleted — skip silently */ }
|
||||
}
|
||||
|
||||
// Post-fetch brand filter (brand lives on the project)
|
||||
@@ -2253,6 +2346,7 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
|
||||
brand_id: t.project_id && projectData[t.project_id] ? projectData[t.project_id].brand_id : null,
|
||||
brand_name: t.project_id && projectData[t.project_id] ? projectData[t.project_id].brand_name : null,
|
||||
comment_count: commentCounts[t.Id || t.id] || 0,
|
||||
thumbnail_url: t.thumbnail || null,
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error('GET /tasks error:', err);
|
||||
@@ -3700,6 +3794,7 @@ app.get('/api/issues', requireAuth, async (req, res) => {
|
||||
for (const issue of issues) {
|
||||
issue.brand_name = names[`brand:${issue.brand_id}`] || null;
|
||||
issue.team_name = names[`team:${issue.team_id}`] || null;
|
||||
issue.thumbnail_url = issue.thumbnail || null;
|
||||
}
|
||||
|
||||
// Count by status for dashboard
|
||||
@@ -3915,6 +4010,28 @@ app.delete('/api/issue-attachments/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Set an issue's thumbnail from one of its image attachments
|
||||
app.patch('/api/issues/:id/thumbnail', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { attachment_id } = req.body;
|
||||
const issue = await nocodb.get('Issues', req.params.id);
|
||||
if (!issue) return res.status(404).json({ error: 'Issue not found' });
|
||||
|
||||
if (attachment_id) {
|
||||
const att = await nocodb.get('IssueAttachments', attachment_id);
|
||||
if (!att) return res.status(404).json({ error: 'Attachment not found' });
|
||||
await nocodb.update('Issues', req.params.id, { thumbnail: att.url || `/api/uploads/${att.filename}` });
|
||||
} else {
|
||||
await nocodb.update('Issues', req.params.id, { thumbnail: null });
|
||||
}
|
||||
|
||||
const updated = await nocodb.get('Issues', req.params.id);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to set thumbnail' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PUBLIC ISSUE ROUTES (NO AUTH) ──────────────────────────────
|
||||
|
||||
// Public: List teams for issue submission
|
||||
@@ -4105,15 +4222,114 @@ app.patch('/api/settings/app', requireAuth, requireRole('superadmin'), (req, res
|
||||
res.json(appSettings);
|
||||
});
|
||||
|
||||
// ─── AUTH MIGRATION (one-time: auth.db → NocoDB) ────────────────
|
||||
|
||||
async function migrateAuthToNocoDB() {
|
||||
const authDbPath = path.join(__dirname, 'auth.db');
|
||||
if (!fs.existsSync(authDbPath)) {
|
||||
console.log(' No auth.db found — skipping auth migration.');
|
||||
return;
|
||||
}
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = require('better-sqlite3');
|
||||
} catch {
|
||||
try {
|
||||
// Fallback: use sqlite3 CLI
|
||||
const { execSync } = require('child_process');
|
||||
const raw = execSync(`sqlite3 "${authDbPath}" "SELECT email, password_hash, nocodb_user_id FROM auth_credentials;"`, { encoding: 'utf8' });
|
||||
const rows = raw.trim().split('\n').filter(Boolean).map(line => {
|
||||
const [email, password_hash, nocodb_user_id] = line.split('|');
|
||||
return { email, password_hash, nocodb_user_id: Number(nocodb_user_id) };
|
||||
});
|
||||
if (rows.length === 0) { console.log(' auth.db is empty — nothing to migrate.'); return; }
|
||||
let migrated = 0, skipped = 0;
|
||||
for (const cred of rows) {
|
||||
try {
|
||||
const user = await nocodb.get('Users', cred.nocodb_user_id);
|
||||
if (!user) { console.warn(` User ${cred.nocodb_user_id} (${cred.email}) not found in NocoDB — skipping.`); skipped++; continue; }
|
||||
if (user.password_hash) { skipped++; continue; }
|
||||
await nocodb.update('Users', cred.nocodb_user_id, { password_hash: cred.password_hash });
|
||||
migrated++;
|
||||
} catch (err) { console.error(` Failed to migrate user ${cred.email}:`, err.message); }
|
||||
}
|
||||
console.log(` Auth migration: ${migrated} migrated, ${skipped} skipped.`);
|
||||
if (migrated > 0) {
|
||||
const bakPath = authDbPath + '.bak';
|
||||
if (!fs.existsSync(bakPath)) { fs.renameSync(authDbPath, bakPath); console.log(' Renamed auth.db → auth.db.bak'); }
|
||||
}
|
||||
return;
|
||||
} catch (cliErr) {
|
||||
console.warn(' Cannot read auth.db (no better-sqlite3 or sqlite3 CLI):', cliErr.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const db = new Database(authDbPath, { readonly: true });
|
||||
try {
|
||||
const creds = db.prepare('SELECT email, password_hash, nocodb_user_id FROM auth_credentials').all();
|
||||
if (creds.length === 0) { console.log(' auth.db is empty — nothing to migrate.'); return; }
|
||||
let migrated = 0, skipped = 0;
|
||||
for (const cred of creds) {
|
||||
try {
|
||||
const user = await nocodb.get('Users', cred.nocodb_user_id);
|
||||
if (!user) { console.warn(` User ${cred.nocodb_user_id} (${cred.email}) not found in NocoDB — skipping.`); skipped++; continue; }
|
||||
if (user.password_hash) { skipped++; continue; }
|
||||
await nocodb.update('Users', cred.nocodb_user_id, { password_hash: cred.password_hash });
|
||||
migrated++;
|
||||
} catch (err) { console.error(` Failed to migrate user ${cred.email}:`, err.message); }
|
||||
}
|
||||
console.log(` Auth migration: ${migrated} migrated, ${skipped} skipped.`);
|
||||
if (migrated > 0) {
|
||||
const bakPath = authDbPath + '.bak';
|
||||
if (!fs.existsSync(bakPath)) { fs.renameSync(authDbPath, bakPath); console.log(' Renamed auth.db → auth.db.bak'); }
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── START SERVER ───────────────────────────────────────────────
|
||||
|
||||
async function startServer() {
|
||||
// Validate required env vars
|
||||
const REQUIRED_ENV = {
|
||||
NOCODB_URL: 'NocoDB base URL (e.g., http://localhost:8090)',
|
||||
NOCODB_TOKEN: 'NocoDB API token',
|
||||
NOCODB_BASE_ID: 'NocoDB base/project ID',
|
||||
};
|
||||
const OPTIONAL_ENV = {
|
||||
SESSION_SECRET: 'Session encryption secret (required in production)',
|
||||
APP_URL: 'Public app URL for email links',
|
||||
CLOUDRON_MAIL_SMTP_SERVER: 'SMTP server for password reset emails',
|
||||
CORS_ORIGIN: 'Allowed CORS origin',
|
||||
};
|
||||
|
||||
let missingRequired = false;
|
||||
for (const [key, desc] of Object.entries(REQUIRED_ENV)) {
|
||||
if (!process.env[key]) {
|
||||
console.error(`MISSING required env var: ${key} — ${desc}`);
|
||||
missingRequired = true;
|
||||
}
|
||||
}
|
||||
if (missingRequired) {
|
||||
console.error('Cannot start server. Set required environment variables and retry.');
|
||||
console.error('See .env.example for a template.');
|
||||
process.exit(1);
|
||||
}
|
||||
for (const [key, desc] of Object.entries(OPTIONAL_ENV)) {
|
||||
if (!process.env[key]) console.warn(` Optional env var not set: ${key} — ${desc}`);
|
||||
}
|
||||
|
||||
console.log('Ensuring required tables...');
|
||||
await ensureRequiredTables();
|
||||
console.log('Running FK column migration...');
|
||||
await ensureFKColumns();
|
||||
await ensureTextColumns();
|
||||
await backfillFKs();
|
||||
console.log('Checking auth migration...');
|
||||
await migrateAuthToNocoDB();
|
||||
console.log('Migration complete.');
|
||||
|
||||
// Verify critical columns exist (belt-and-suspenders check)
|
||||
|
||||
Reference in New Issue
Block a user