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

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

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

29
server/.env.example Normal file
View 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

View File

@@ -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
View 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 };

View File

@@ -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",

View File

@@ -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"

View File

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