From c31e6222d7ab2b5374eeed6dbb935ab407515fcf Mon Sep 17 00:00:00 2001 From: fahed Date: Wed, 4 Mar 2026 11:47:27 +0300 Subject: [PATCH] feat: consolidate auth into NocoDB, add password reset, health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- client/src/App.jsx | 13 +- client/src/i18n/ar.json | 19 ++ client/src/i18n/en.json | 19 ++ client/src/pages/ForgotPassword.jsx | 111 ++++++++++ client/src/pages/Login.jsx | 6 +- client/src/pages/ResetPassword.jsx | 141 ++++++++++++ server/.env.example | 29 +++ server/helpers.js | 16 ++ server/mail.js | 37 ++++ server/package-lock.json | 12 +- server/package.json | 3 +- server/server.js | 322 +++++++++++++++++++++++----- 12 files changed, 670 insertions(+), 58 deletions(-) create mode 100644 client/src/pages/ForgotPassword.jsx create mode 100644 client/src/pages/ResetPassword.jsx create mode 100644 server/.env.example create mode 100644 server/mail.js diff --git a/client/src/App.jsx b/client/src/App.jsx index f011b00..d15cea8 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() {
Loading...
}> : } /> + : } /> + : } /> } /> } /> } /> @@ -320,7 +329,9 @@ function App() { - + + + diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json index d2fc58e..3225c75 100644 --- a/client/src/i18n/ar.json +++ b/client/src/i18n/ar.json @@ -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": "اكتب تعليقاً...", diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 33d9ffb..d97e60d 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -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...", diff --git a/client/src/pages/ForgotPassword.jsx b/client/src/pages/ForgotPassword.jsx new file mode 100644 index 0000000..99c820f --- /dev/null +++ b/client/src/pages/ForgotPassword.jsx @@ -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 ( +
+
+
+
+ +
+

{t('forgotPassword.title')}

+

{t('forgotPassword.subtitle')}

+
+ +
+ {sent ? ( +
+
+ +
+

{t('forgotPassword.success')}

+ + + {t('forgotPassword.backToLogin')} + +
+ ) : ( +
+
+ +
+ + 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 + /> +
+
+ + {error && ( +
+ +

{error}

+
+ )} + + + +
+ + + {t('forgotPassword.backToLogin')} + +
+
+ )} +
+
+
+ ) +} diff --git a/client/src/pages/Login.jsx b/client/src/pages/Login.jsx index 1c9ff7b..da647f6 100644 --- a/client/src/pages/Login.jsx +++ b/client/src/pages/Login.jsx @@ -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 && (

- {t('login.forgotPassword')} + + {t('login.forgotPassword')} +

)} diff --git a/client/src/pages/ResetPassword.jsx b/client/src/pages/ResetPassword.jsx new file mode 100644 index 0000000..0e89d7b --- /dev/null +++ b/client/src/pages/ResetPassword.jsx @@ -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 ( +
+
+
+ +

{t('resetPassword.invalidToken')}

+ + {t('resetPassword.goToLogin')} + +
+
+
+ ) + } + + 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 ( +
+
+
+
+ +
+

{t('resetPassword.title')}

+

{t('resetPassword.subtitle')}

+
+ +
+ {success ? ( +
+
+ +
+

{t('resetPassword.success')}

+ + + {t('resetPassword.goToLogin')} + +
+ ) : ( +
+
+ +
+ + 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 + /> +
+
+ +
+ +
+ + 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} + /> +
+
+ + {error && ( +
+ +

{error}

+
+ )} + + +
+ )} +
+
+
+ ) +} diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..44efc11 --- /dev/null +++ b/server/.env.example @@ -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 diff --git a/server/helpers.js b/server/helpers.js index 009e8de..5853f41 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -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, }; diff --git a/server/mail.js b/server/mail.js new file mode 100644 index 0000000..397f145 --- /dev/null +++ b/server/mail.js @@ -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 }; diff --git a/server/package-lock.json b/server/package-lock.json index 10336cc..538f488 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index ceac052..2995bee 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/server.js b/server/server.js index 565dfea..bdf85d0 100644 --- a/server/server.js +++ b/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: `
+

Password Reset

+

Hello ${user.name || ''},

+

Click below to reset your password:

+

+ Reset Password +

+

This link expires in 1 hour. If you didn't request this, ignore this email.

+
`, + 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)