feat(profile): read-first редизайн страницы пользователя, per-field как в задачах
- Раскладка: шапка-профиль → Основное/Контакты → Роль и доступ/Поля+Замещение → вторичные разделы чипами - ProfileFieldRow: двойной клик/карандаш, редактор в слоте, Esc, optimistic-кэш, все типы полей - Замещение: надпись + ⊕ popover, список под строкой - Удалены RHF-форма, zod-схема и кнопки «Сохранить» - server: PATCH /api/users/:id (частичное обновление, admin-градация, аудит) - server: history-number в REST PATCH пишет историю + пересчёт средней
This commit is contained in:
@@ -338,7 +338,154 @@ export function registerUserRoutes(router: ReturnType<typeof import("express").R
|
||||
}
|
||||
);
|
||||
|
||||
// Change user password endpoint for admins
|
||||
// Частичное обновление системных полей профиля (редизайн профиля):
|
||||
// применяются только переданные ключи, непереданные поля не затираются.
|
||||
// Права — как у PUT: admin / сам пользователь / руководитель по орг-дереву (canManageUser).
|
||||
router.patch('/api/users/:id(\\d+)',
|
||||
validateTenantAccess,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isFinite(userId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Некорректный ID пользователя'
|
||||
});
|
||||
}
|
||||
const user = await storage.getUser(userId);
|
||||
|
||||
if (!user || user.organizationId !== req.organizationId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Пользователь не найден'
|
||||
});
|
||||
}
|
||||
|
||||
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
|
||||
if (!canEdit) {
|
||||
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
|
||||
}
|
||||
|
||||
const isAdmin = req.user?.appRole === 'admin';
|
||||
const body = req.body ?? {};
|
||||
|
||||
// appRole/isActive/statusId — только админ (как в PUT); попытка не-админа — явный 403
|
||||
const adminOnlyKeys = ['appRole', 'isActive', 'statusId'] as const;
|
||||
if (!isAdmin && adminOnlyKeys.some((key) => body[key] !== undefined)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Изменение роли, активности и статуса доступно только администратору'
|
||||
});
|
||||
}
|
||||
|
||||
if (body.appRole !== undefined && !['admin', 'user', 'accountant'].includes(body.appRole)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректная роль пользователя' });
|
||||
}
|
||||
|
||||
if (body.email !== undefined) {
|
||||
const email = String(body.email).trim();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректный email' });
|
||||
}
|
||||
const existingWithEmail = await storage.getUserByEmail(email, req.organizationId!);
|
||||
if (existingWithEmail && existingWithEmail.id !== userId) {
|
||||
return res.status(400).json({ success: false, error: 'Пользователь с таким email уже существует' });
|
||||
}
|
||||
body.email = email;
|
||||
}
|
||||
|
||||
const editableKeys = [
|
||||
'firstName', 'lastName', 'middleName', 'email', 'phone', 'position',
|
||||
'additionalPhones', 'additionalEmails',
|
||||
] as const;
|
||||
const updates: Partial<typeof user> = {};
|
||||
for (const key of editableKeys) {
|
||||
if (body[key] !== undefined) {
|
||||
(updates as Record<string, unknown>)[key] = body[key];
|
||||
}
|
||||
}
|
||||
if (isAdmin) {
|
||||
for (const key of adminOnlyKeys) {
|
||||
if (body[key] !== undefined) {
|
||||
(updates as Record<string, unknown>)[key] = body[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'Нет полей для обновления' });
|
||||
}
|
||||
|
||||
const updatedUser = await storage.updateUser(userId, updates);
|
||||
|
||||
// Аудит — как в PUT: контактные системные поля профиля (+ email, который PATCH также принимает)
|
||||
const auditFields = [
|
||||
{ name: 'Email', old: user.email, new: updates.email },
|
||||
{ name: 'Телефон', old: user.phone, new: updates.phone },
|
||||
{ name: 'Дополнительные телефоны', old: user.additionalPhones, new: updates.additionalPhones },
|
||||
{ name: 'Дополнительные email', old: user.additionalEmails, new: updates.additionalEmails },
|
||||
];
|
||||
for (const auditField of auditFields) {
|
||||
if (auditField.new === undefined) continue;
|
||||
if (JSON.stringify(auditField.old ?? null) !== JSON.stringify(auditField.new ?? null)) {
|
||||
await storage.createUserProfileAuditLog({
|
||||
userId,
|
||||
organizationId: req.organizationId!,
|
||||
action: 'field_value.updated',
|
||||
fieldName: auditField.name,
|
||||
oldValue: auditField.old ?? null,
|
||||
newValue: auditField.new ?? null,
|
||||
changedBy: req.user!.id,
|
||||
changedByName: req.user ? `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim() : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin && body.appRole !== undefined && body.appRole !== user.appRole) {
|
||||
logAudit({
|
||||
action: 'user.role.changed',
|
||||
userId: req.user!.id,
|
||||
organizationId: req.organizationId!,
|
||||
details: {
|
||||
targetUserId: userId,
|
||||
oldRole: user.appRole,
|
||||
newRole: body.appRole,
|
||||
},
|
||||
ip: getClientIp(req),
|
||||
userAgent: req.headers['user-agent'] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Пользователь обновлен',
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
middleName: updatedUser.middleName,
|
||||
position: updatedUser.position,
|
||||
phone: updatedUser.phone,
|
||||
appRole: updatedUser.appRole,
|
||||
isActive: updatedUser.isActive,
|
||||
statusId: updatedUser.statusId,
|
||||
emailVerified: updatedUser.emailVerified,
|
||||
lastLogin: updatedUser.lastLogin,
|
||||
createdAt: updatedUser.createdAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Patch user error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Ошибка при обновлении пользователя'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
router.put('/api/users/:id/password',
|
||||
requirePermission('users.manage'),
|
||||
validateTenantAccess,
|
||||
|
||||
Reference in New Issue
Block a user