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:
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