screentinker/server/routes/auth.js
ScreenTinker 388e9e6ab8 Admin password reset + widget visibility fix
Password reset for other users:
- New PUT /api/auth/users/:id/password endpoint
- Superadmin can reset any local user; admin can reset role=user
  members of teams they own only (cannot reset other admins or
  superadmins, cannot self-reset — that goes through PUT /me with
  current_password)
- OAuth users are excluded (no password to reset)
- Rate-limited 20 req/min/IP to cap blast radius if an admin session
  is compromised
- Explicit audit log entry "password_reset_for_user / target: <email>"
  on every reset; activity logger's summarizeAction never reads the
  password field, so the password value is not stored anywhere

Frontend: Reset Password button in the Admin user table and Settings
> User Management table. Shown only for local-auth users that aren't
the current user; prompts for an 8+ char password.

Widgets visibility fix:
- routes/widgets.js had `const isAdmin = req.user.role === 'superadmin'`
  which mislabeled superadmin as admin and silently restricted real
  admins (role=admin) to seeing only their own widgets. Now matches
  /auth/users behavior: superadmin sees all, admin sees own + public
  + widgets owned by members of teams they own, user sees own + public.

7 new i18n keys (admin.reset_password, admin.prompt_reset_password,
admin.toast.password_min_8, admin.toast.password_reset, and the
matching settings.user.* / settings.toast.* trio). 1024 keys total,
parity 100% across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:45:25 -05:00

371 lines
16 KiB
JavaScript

const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const https = require('https');
const { v4: uuidv4 } = require('uuid');
const { OAuth2Client } = require('google-auth-library');
const { db } = require('../db/database');
const { generateToken, requireAuth, requireAdmin, requireSuperAdmin } = require('../middleware/auth');
const { logActivity } = require('../services/activity');
const config = require('../config');
function logFailedLogin(email, ip, reason) {
try {
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (NULL, ?, ?, ?)')
.run('auth:login_failed', `${email} - ${reason}`, ip);
} catch {}
}
function logSuccessfulLogin(userId, email, ip) {
try {
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)')
.run(userId, 'auth:login_success', email, ip);
db.prepare("UPDATE users SET last_login = strftime('%s','now') WHERE id = ?").run(userId);
} catch {}
}
// ==================== Local Auth ====================
// Returns true if new account creation is allowed at this moment.
// First-user setup (empty DB) is always allowed so a fresh install can be initialized.
function canRegister() {
if (!config.disableRegistration) return true;
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
return userCount === 0;
}
// Register
router.post('/register', (req, res) => {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const { email, password, name } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existing) return res.status(409).json({ error: 'Email already registered' });
const id = uuidv4();
const passwordHash = bcrypt.hashSync(password, 10);
// First user becomes admin with enterprise plan (self-hosted) or free plan with Pro trial
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user';
const isFirstUser = userCount === 0;
const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial
const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000);
db.prepare(`
INSERT INTO users (id, email, name, password_hash, auth_provider, role, plan_id, trial_started, trial_plan)
VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?)
`).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(id);
const token = generateToken(user);
res.status(201).json({ token, user });
});
// Login
router.post('/login', (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const user = db.prepare('SELECT * FROM users WHERE email = ? AND auth_provider = ?').get(email.toLowerCase(), 'local');
if (!user) {
logFailedLogin(email, req.ip, 'User not found');
return res.status(401).json({ error: 'Invalid email or password' });
}
if (!bcrypt.compareSync(password, user.password_hash)) {
logFailedLogin(email, req.ip, 'Wrong password');
return res.status(401).json({ error: 'Invalid email or password' });
}
logSuccessfulLogin(user.id, email, req.ip);
const token = generateToken(user);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
});
// ==================== Google OAuth ====================
router.post('/google', async (req, res) => {
const { credential } = req.body;
if (!credential) return res.status(400).json({ error: 'Google credential required' });
try {
// Verify the Google ID token
const payload = await verifyGoogleToken(credential);
if (!payload) return res.status(401).json({ error: 'Invalid Google token' });
const { email, name, picture, sub: googleId } = payload;
// Find or create user
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user';
const isFirst = userCount === 0;
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
db.prepare(`
INSERT INTO users (id, email, name, auth_provider, provider_id, avatar_url, role, plan_id, trial_started, trial_plan)
VALUES (?, ?, ?, 'google', ?, ?, ?, ?, ?, ?)
`).run(id, email.toLowerCase(), name || '', googleId, picture || '', role, plan, trialStarted, trialStarted ? 'pro' : null);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
} else if (user.auth_provider !== 'google') {
// Existing account with different provider — do NOT silently overwrite auth_provider.
// If they have a local password, require them to log in locally and link from settings.
if (user.password_hash) {
return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' });
}
// No password (e.g. Microsoft → Google switch) — allow linking
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?')
.run('google', googleId, picture || user.avatar_url, user.id);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
const token = generateToken(user);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
console.error('Google auth error:', err);
res.status(401).json({ error: 'Google authentication failed' });
}
});
async function verifyGoogleToken(credential) {
const client = new OAuth2Client(config.googleClientId);
try {
const ticket = await client.verifyIdToken({
idToken: credential,
audience: config.googleClientId || undefined,
});
return ticket.getPayload();
} catch (e) {
// Fallback: if credential is an access token, verify via tokeninfo
try {
const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${credential}`);
if (!res.ok) throw new Error('Invalid token');
return await res.json();
} catch {
throw new Error('Google token verification failed: ' + e.message);
}
}
}
// ==================== Microsoft OAuth ====================
router.post('/microsoft', async (req, res) => {
const { access_token } = req.body;
if (!access_token) return res.status(400).json({ error: 'Microsoft access token required' });
try {
// Use the access token to get user profile from Microsoft Graph
const profile = await getMicrosoftProfile(access_token);
if (!profile || !profile.mail && !profile.userPrincipalName) {
return res.status(401).json({ error: 'Could not get Microsoft profile' });
}
const email = (profile.mail || profile.userPrincipalName).toLowerCase();
const name = profile.displayName || '';
const microsoftId = profile.id;
// Find or create user
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user';
const isFirst = userCount === 0;
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
db.prepare(`
INSERT INTO users (id, email, name, auth_provider, provider_id, role, plan_id, trial_started, trial_plan)
VALUES (?, ?, ?, 'microsoft', ?, ?, ?, ?, ?)
`).run(id, email, name, microsoftId, role, plan, trialStarted, trialStarted ? 'pro' : null);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
} else if (user.auth_provider !== 'microsoft') {
// Existing account with different provider — do NOT silently overwrite auth_provider.
if (user.password_hash) {
return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' });
}
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?')
.run('microsoft', microsoftId, user.id);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
const token = generateToken(user);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
console.error('Microsoft auth error:', err);
res.status(401).json({ error: 'Microsoft authentication failed' });
}
});
function getMicrosoftProfile(accessToken) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'graph.microsoft.com',
path: '/v1.0/me',
headers: { Authorization: `Bearer ${accessToken}` }
};
https.get(options, (resp) => {
let data = '';
resp.on('data', chunk => data += chunk);
resp.on('end', () => {
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
});
}).on('error', reject);
});
}
// ==================== User Management ====================
// Get current user
router.get('/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Update current user
router.put('/me', requireAuth, (req, res) => {
const { name, password, current_password } = req.body;
if (name) {
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(name, req.user.id);
}
if (password) {
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const row = db.prepare('SELECT password_hash, auth_provider FROM users WHERE id = ?').get(req.user.id);
if (!row) return res.status(404).json({ error: 'User not found' });
if (row.auth_provider !== 'local') {
return res.status(400).json({ error: `Your account signs in via ${row.auth_provider}. Manage your password there.` });
}
if (row.password_hash) {
if (!current_password || !bcrypt.compareSync(current_password, row.password_hash)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
}
const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(hash, req.user.id);
}
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(req.user.id);
res.json(user);
});
// List users - superadmins see all, admins see team members only
router.get('/users', requireAuth, requireAdmin, (req, res) => {
if (req.user.role === 'superadmin') {
const users = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, created_at, last_login FROM users ORDER BY created_at ASC').all();
res.json(users);
} else {
// Admin sees themselves + users in their teams
const users = db.prepare(`
SELECT DISTINCT u.id, u.email, u.name, u.role, u.auth_provider, u.avatar_url, u.plan_id, u.created_at
FROM users u
LEFT JOIN team_members tm ON u.id = tm.user_id
WHERE u.id = ? OR tm.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
ORDER BY u.created_at ASC
`).all(req.user.id, req.user.id);
res.json(users);
}
});
// Delete user (superadmin only)
router.delete('/users/:id', requireAuth, requireSuperAdmin, (req, res) => {
if (req.params.id === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Update user role (superadmin only)
router.put('/users/:id/role', requireAuth, requireSuperAdmin, (req, res) => {
const { role } = req.body;
if (!['user', 'admin', 'superadmin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (req.params.id === req.user.id && role !== 'superadmin') return res.status(400).json({ error: 'Cannot demote yourself' });
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
res.json({ success: true });
});
// Admin password reset for another user.
// Superadmins: can reset any local user. Admins: can reset members of teams
// they own (and never a superadmin). Self-reset routes through PUT /me with
// current_password — this endpoint is the override path.
router.put('/users/:id/password', requireAuth, requireAdmin, (req, res) => {
const { password } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'Use Settings > Change Password for your own account' });
}
const target = db.prepare('SELECT id, email, role, auth_provider FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.auth_provider !== 'local') {
return res.status(400).json({ error: `User signs in via ${target.auth_provider} — password reset does not apply` });
}
if (req.user.role !== 'superadmin') {
// Admin path: must own a team that includes the target, and target must
// be a regular user (cannot reset another admin's or a superadmin's
// password — that would be a lateral-takeover vector).
if (target.role !== 'user') {
return res.status(403).json({ error: 'Admins can only reset passwords for regular users' });
}
const sharedOwnedTeam = db.prepare(`
SELECT 1 FROM team_members tm_admin
JOIN team_members tm_target ON tm_admin.team_id = tm_target.team_id
WHERE tm_admin.user_id = ? AND tm_admin.role = 'owner'
AND tm_target.user_id = ?
LIMIT 1
`).get(req.user.id, req.params.id);
if (!sharedOwnedTeam) {
return res.status(403).json({ error: 'You can only reset passwords for members of teams you own' });
}
}
const hash = bcrypt.hashSync(password, 10);
db.prepare("UPDATE users SET password_hash = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(hash, req.params.id);
// Explicit audit entry — the generic activity logger captures the route
// and target id, but a labeled detail string makes the audit log readable.
// Never include the password; just who reset whose password.
logActivity(req.user.id, 'password_reset_for_user', `target: ${target.email}`, null, req.ip);
res.json({ success: true });
});
// Get auth config (public - tells frontend which providers are available)
router.get('/config', (req, res) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
res.json({
googleEnabled: !!config.googleClientId,
googleClientId: config.googleClientId,
microsoftEnabled: !!config.microsoftClientId,
microsoftClientId: config.microsoftClientId,
microsoftTenantId: config.microsoftTenantId,
localEnabled: true,
needsSetup: userCount === 0,
registration_enabled: !config.disableRegistration || userCount === 0,
});
});
module.exports = router;