- 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
115 lines
4.7 KiB
JavaScript
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;
|