Files
openStack/backend/src/routes/users.js
T
x3 c3c77333db feat(backend): invites, notifications, chat, uploads, teams, groups, hardening
- Pending workspace invites with accept/decline + bell notifications;
  invite permission extendable via workspace.invite group right
- Notifications API (list/unread, mark read, read-all) + realtime push
  to per-user socket rooms; chat messages notify other members
- Workspace team chat API (list/post/delete) + chat:join socket rooms
- Image uploads (multer, 5MB image-only) served from /uploads
- Teams (managers/members), team-owned workspaces (TEAM badge), team
  task boards (team_id) and task assignees (workspace members only)
- Permission groups with teams.create/edit/invite + workspace.invite,
  default Team Creators seeding, hasPerm/canManageTeam guards
- Public registration disabled after install; admins create users
  (pending invites auto-link); users gain bg_glass setting
- Crash fix: express-async-errors + 400 on malformed IDs instead of
  unhandled rejections killing the server
2026-09-22 14:27:33 +02:00

115 lines
4.7 KiB
JavaScript

import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
const LOCALES = ['de', 'en', 'zh'];
const THEMES = ['light', 'dark', 'system'];
// GET /api/users/search?email= — find user by exact email (for invites)
router.get('/search', async (req, res) => {
const { email = '' } = req.query;
if (!email) return res.status(400).json({ error: 'email required' });
const { rows } = await query(
'SELECT id, email, name, avatar FROM users WHERE email = $1',
[String(email).toLowerCase()],
);
return res.json({ user: rows[0] || null });
});
// GET /api/users/discover?q= — fuzzy user discovery for invites/mentions.
// Returns id, name, email, avatar (never password hashes).
router.get('/discover', async (req, res) => {
const q = String(req.query.q || '').trim().slice(0, 60);
if (q.length < 2) return res.json({ users: [] });
const { rows } = await query(
`SELECT id, name, email, avatar FROM users
WHERE name ILIKE $1 OR email ILIKE $1
ORDER BY name LIMIT 10`,
[`%${q}%`],
);
return res.json({ users: rows });
});
// PATCH /api/users/me — profile + appearance settings
router.patch('/me', async (req, res) => {
const { name, email, avatar, locale, theme, bg_image, bg_blur, bg_glass } = req.body ?? {};
if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 100)) {
return res.status(400).json({ error: 'Name must be 1-100 chars' });
}
if (email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(email))) {
return res.status(400).json({ error: 'Valid email required' });
}
if (locale !== undefined && !LOCALES.includes(locale)) {
return res.status(400).json({ error: 'Locale must be de, en or zh' });
}
if (theme !== undefined && !THEMES.includes(theme)) {
return res.status(400).json({ error: 'Theme must be light, dark or system' });
}
if (avatar !== undefined && String(avatar).length > 500) {
return res.status(400).json({ error: 'Avatar too long (max 500 chars, emoji or URL)' });
}
if (bg_image !== undefined && String(bg_image).length > 2000) {
return res.status(400).json({ error: 'Background URL too long' });
}
try {
const { rows } = await query(
`UPDATE users SET
name = COALESCE($1, name),
email = COALESCE($2, email),
avatar = COALESCE($3, avatar),
locale = COALESCE($4, locale),
theme = COALESCE($5, theme),
bg_image = COALESCE($6, bg_image),
bg_blur = COALESCE($7, bg_blur),
bg_glass = COALESCE($8, bg_glass)
WHERE id = $9
RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, bg_glass, created_at`,
[
name !== undefined ? String(name).trim() : null,
email !== undefined ? String(email).toLowerCase() : null,
avatar !== undefined ? String(avatar).slice(0, 500) : null,
locale !== undefined ? locale : null,
theme !== undefined ? theme : null,
bg_image !== undefined ? String(bg_image).slice(0, 2000) : null,
bg_blur !== undefined ? Boolean(bg_blur) : null,
bg_glass !== undefined ? Boolean(bg_glass) : null,
req.user.id,
],
);
const u = rows[0];
return res.json({
user: {
id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin,
avatar: u.avatar || '', locale: u.locale, theme: u.theme,
bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false,
bgGlass: u.bg_glass !== false, createdAt: u.created_at,
},
});
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Email already taken' });
console.error('[users/me]', err.message);
return res.status(500).json({ error: 'Update failed' });
}
});
// PATCH /api/users/me/password { currentPassword, newPassword }
router.patch('/me/password', async (req, res) => {
const { currentPassword = '', newPassword = '' } = req.body ?? {};
if (String(newPassword).length < 8) {
return res.status(400).json({ error: 'New password must be at least 8 chars' });
}
const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
const ok = await bcrypt.compare(String(currentPassword), rows[0].password_hash);
if (!ok) return res.status(401).json({ error: 'Current password is wrong' });
const hash = await bcrypt.hash(String(newPassword), 12);
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.id]);
return res.json({ ok: true });
});
export default router;