Files
Art-gallery/server/routes/users.js
T
Danila KhodjaefandCursor cfee69c9a6 Keep prod users local and fix post-restore id sequences.
Prod restore skips users/session/audit, syncs serial sequences after load, and user create re-aligns users_id_seq so new accounts are not misreported as duplicates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:26:52 +03:00

258 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const bcrypt = require('bcryptjs');
const pool = require('../db');
const { requirePermission } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const { ALL_PERMISSIONS, normalizePermissions } = require('../permissions');
const router = express.Router();
const USER_SELECT = `id, username, role, permissions, is_active, created_at, last_login_at`;
function mapUser(row) {
return {
id: row.id,
username: row.username,
role: row.role,
permissions: row.role === 'admin' ? [...ALL_PERMISSIONS] : normalizePermissions(row.permissions),
is_active: row.is_active !== false,
created_at: row.created_at,
last_login_at: row.last_login_at,
};
}
function parseRole(raw) {
if (raw === 'admin' || raw === 'curator') return raw;
return null;
}
async function countActiveAdmins(client = pool) {
const { rows } = await client.query(
`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin' AND is_active = true`
);
return rows[0].n;
}
async function clearUserSessions(userId) {
// connect-pg-simple stores session JSON with userId
await pool.query(`DELETE FROM session WHERE (sess->>'userId')::int = $1`, [userId]);
}
/** Keep users_id_seq ahead of existing rows (restore inserts explicit ids). */
async function syncUsersIdSequence() {
await pool.query(
`SELECT setval(
pg_get_serial_sequence('users', 'id'),
GREATEST(1, COALESCE((SELECT MAX(id) FROM users), 1)),
true
)`
);
}
router.use(requirePermission('users'));
router.get('/', async (_req, res) => {
try {
const { rows } = await pool.query(
`SELECT ${USER_SELECT} FROM users ORDER BY username ASC`
);
res.json({ users: rows.map(mapUser), permissions: ALL_PERMISSIONS });
} catch (err) {
console.error('Users list error:', err.message);
res.status(500).json({ error: 'Failed to list users' });
}
});
router.post('/', async (req, res) => {
try {
const username =
typeof req.body?.username === 'string' ? req.body.username.trim() : '';
const password = typeof req.body?.password === 'string' ? req.body.password : '';
const role = parseRole(req.body?.role) || 'curator';
const permissions = normalizePermissions(req.body?.permissions);
if (!username || username.length < 2 || username.length > 64) {
return res.status(400).json({ error: 'Username must be 264 characters' });
}
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, . _ -' });
}
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (role === 'admin' && req.curatorUser.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can create admin accounts' });
}
const passwordHash = await bcrypt.hash(password, 10);
const storedPermissions = role === 'admin' ? ALL_PERMISSIONS : permissions;
const { rows: taken } = await pool.query(
`SELECT username FROM users WHERE LOWER(username) = LOWER($1) LIMIT 1`,
[username]
);
if (taken[0]) {
return res.status(409).json({ error: 'Username already exists' });
}
await syncUsersIdSequence();
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, role, permissions, is_active)
VALUES ($1, $2, $3, $4::text[], true)
RETURNING ${USER_SELECT}`,
[username, passwordHash, role, storedPermissions]
);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'user.create',
resourceType: 'user',
resourceId: rows[0].id,
details: { username, role, permissions: storedPermissions },
req,
});
res.status(201).json({ user: mapUser(rows[0]) });
} catch (err) {
if (err.code === '23505') {
if (err.constraint === 'users_pkey') {
console.error('Users create PK conflict (sequence lag):', err.detail || err.message);
return res.status(409).json({
error: 'Could not allocate user id — retry create (sequence was out of sync)',
});
}
return res.status(409).json({ error: 'Username already exists' });
}
console.error('Users create error:', err.message);
res.status(500).json({ error: 'Failed to create user' });
}
});
router.patch('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
const { rows: existingRows } = await pool.query(
`SELECT ${USER_SELECT} FROM users WHERE id = $1`,
[id]
);
if (!existingRows[0]) return res.status(404).json({ error: 'User not found' });
const existing = existingRows[0];
let role = existing.role;
if (req.body?.role !== undefined) {
const parsed = parseRole(req.body.role);
if (!parsed) return res.status(400).json({ error: 'Invalid role' });
if (parsed === 'admin' && req.curatorUser.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can promote to admin' });
}
if (existing.role === 'admin' && parsed !== 'admin' && req.curatorUser.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can demote admins' });
}
role = parsed;
}
let permissions = normalizePermissions(existing.permissions);
if (req.body?.permissions !== undefined) {
permissions = normalizePermissions(req.body.permissions);
}
if (role === 'admin') {
permissions = [...ALL_PERMISSIONS];
}
let isActive = existing.is_active !== false;
if (req.body?.is_active !== undefined) {
if (typeof req.body.is_active !== 'boolean') {
return res.status(400).json({ error: 'is_active must be boolean' });
}
isActive = req.body.is_active;
}
if (
existing.role === 'admin' &&
existing.is_active !== false &&
(role !== 'admin' || !isActive)
) {
const admins = await countActiveAdmins();
if (admins <= 1) {
return res.status(400).json({ error: 'Cannot deactivate or demote the last active admin' });
}
}
const { rows } = await pool.query(
`UPDATE users
SET role = $2, permissions = $3::text[], is_active = $4
WHERE id = $1
RETURNING ${USER_SELECT}`,
[id, role, permissions, isActive]
);
if (!isActive) {
await clearUserSessions(id);
}
await logCuratorAction({
userId: req.curatorUser.id,
action: 'user.update',
resourceType: 'user',
resourceId: id,
details: {
username: rows[0].username,
role,
permissions,
is_active: isActive,
},
req,
});
res.json({ user: mapUser(rows[0]) });
} catch (err) {
console.error('Users update error:', err.message);
res.status(500).json({ error: 'Failed to update user' });
}
});
router.post('/:id/password', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
const password = typeof req.body?.password === 'string' ? req.body.password : '';
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const { rows: existingRows } = await pool.query(
`SELECT id, username, role FROM users WHERE id = $1`,
[id]
);
if (!existingRows[0]) return res.status(404).json({ error: 'User not found' });
if (existingRows[0].role === 'admin' && req.curatorUser.role !== 'admin' && req.curatorUser.id !== id) {
return res.status(403).json({ error: 'Only admins can reset another admin password' });
}
const passwordHash = await bcrypt.hash(password, 10);
await pool.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [id, passwordHash]);
await clearUserSessions(id);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'user.reset_password',
resourceType: 'user',
resourceId: id,
details: { username: existingRows[0].username },
req,
});
res.json({ ok: true });
} catch (err) {
console.error('Users password error:', err.message);
res.status(500).json({ error: 'Failed to reset password' });
}
});
module.exports = router;