Files
iistwin/server/routes/auth.users.routes.ts
Ильяс Султанов 4d1dc2b6f9 fix(users): fieldValues в реальном обработчике /api/users/list + горизонтальный скролл таблицы
Причина «поля профиля не отображаются»: /api/users/list обслуживает СТАРЫЙ
обработчик в auth.users.routes.ts (зарегистрирован раньше дубля handleListUsers
в user.routes.ts — проверено: в ответе нет total/pageSize). includeFieldValues
добавлен в оба, но реальный — теперь тоже отдаёт fieldValues.
Плюс overflow-x-auto на контейнере таблицы — прокрутка при переполнении колонок.
2026-09-29 15:32:47 +03:00

748 lines
31 KiB
TypeScript
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.

import { Router } from "express";
import { z } from 'zod';
import { storage } from "../storage";
import { authService } from "../services/auth.service";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { db } from "../db";
import { inArray } from "drizzle-orm";
import { userProfileFieldValues } from "@shared/schema";
import {
createUserCustomFieldSchema, updateUserCustomFieldSchema,
setUserCustomValueSchema,
} from "@shared/schema";
import { logAudit, getClientIp } from "../utils/audit";
export function registerUserManagementRoutes(router: Router): void {
// === User Custom Fields routes ===
router.get('/api/user-custom-fields',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const fields = await storage.getUserCustomFields(req.organizationId!);
res.json({ success: true, fields });
} catch (error) {
console.error('Get user custom fields error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.post('/api/user-custom-fields',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
validateRequest(createUserCustomFieldSchema),
async (req: AuthenticatedRequest, res) => {
try {
const existingFields = await storage.getUserCustomFields(req.organizationId!);
if (existingFields.some(f => f.code === req.body.code)) {
return res.status(400).json({ success: false, error: 'Поле с таким кодом уже существует' });
}
const field = await storage.createUserCustomField(req.organizationId!, req.body);
res.status(201).json({ success: true, field });
} catch (error) {
console.error('Create user custom field error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.patch('/api/user-custom-fields/:id',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
validateRequest(updateUserCustomFieldSchema),
async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
const existing = await storage.getUserCustomField(id, req.organizationId!);
if (!existing) {
return res.status(404).json({ success: false, error: 'Поле не найдено' });
}
if (req.body.code && req.body.code !== existing.code) {
const allFields = await storage.getUserCustomFields(req.organizationId!);
if (allFields.some(f => f.code === req.body.code && f.id !== id)) {
return res.status(400).json({ success: false, error: 'Поле с таким кодом уже существует' });
}
}
const field = await storage.updateUserCustomField(id, req.organizationId!, req.body);
res.json({ success: true, field });
} catch (error) {
console.error('Update user custom field error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.delete('/api/user-custom-fields/:id',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
await storage.deleteUserCustomField(id, req.organizationId!);
res.json({ success: true, message: 'Поле удалено' });
} catch (error) {
console.error('Delete user custom field error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.post('/api/user-custom-fields/reorder',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const { fieldIds } = req.body;
if (!Array.isArray(fieldIds)) {
return res.status(400).json({ success: false, error: 'fieldIds должен быть массивом' });
}
await storage.reorderUserCustomFields(req.organizationId!, fieldIds);
res.json({ success: true });
} catch (error) {
console.error('Reorder user custom fields error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// Get custom values for a user
router.get('/api/users/:userId/custom-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const appRole = req.user?.appRole;
if (appRole !== 'admin' && req.user?.id !== userId) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const values = await storage.getUserCustomValues(userId, req.organizationId!);
res.json({ success: true, values });
} catch (error) {
console.error('Get user custom values error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// Set custom values for a user (admin only, or self)
router.put('/api/users/:userId/custom-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const appRole = req.user?.appRole;
if (appRole !== 'admin' && req.user?.id !== userId) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const { values } = req.body;
if (!Array.isArray(values)) {
return res.status(400).json({ success: false, error: 'values должен быть массивом' });
}
await storage.setUserCustomValues(userId, values, req.organizationId!);
const updatedValues = await storage.getUserCustomValues(userId, req.organizationId!);
res.json({ success: true, values: updatedValues });
} catch (error) {
console.error('Set user custom values error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// === User management routes ===
// Public list endpoint — accessible to all org members (needed for chats, assignments, etc.)
router.get('/api/users/list',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const users = await storage.getUsersByOrganization(req.organizationId!);
const orgRoles = await storage.getRoles(req.organizationId!);
let filtersObj: Record<string, string> = {};
try { filtersObj = req.query.filters ? JSON.parse(req.query.filters as string) : {}; } catch {}
const sortColumn = (req.query.sortColumn as string) || '';
const sortDirection = (req.query.sortDirection as string) === 'desc' ? 'desc' : 'asc';
// Build role membership map for all users
const roleMembersMap = new Map<number, Array<{ id: number; name: string }>>();
for (const r of orgRoles) {
for (const m of r.members) {
if (!roleMembersMap.has(m.userId)) roleMembersMap.set(m.userId, []);
roleMembersMap.get(m.userId)!.push({ id: r.id, name: r.name });
}
}
const getUserColVal = (u: any, col: string): string => {
if (col === 'name') return `${u.firstName} ${u.middleName || ''} ${u.lastName}`.trim();
if (col === 'email') return u.email || '';
if (col === 'role') return u.appRole || '';
if (col === 'status') return u.isActive ? 'Активен' : 'Неактивен';
if (col === 'position') return u.position || '';
return '';
};
// Значения кастомных полей профиля — для реестра пользователей (выбор колонок).
// ВНИМАНИЕ: этот обработчик /api/users/list фактически обслуживает запросы
// (зарегистрирован раньше дубля в user.routes.ts handleListUsers).
const includeFieldValues = req.query.includeFieldValues === '1';
const fieldValuesMap = new Map<number, Record<number, unknown>>();
if (includeFieldValues && users.length > 0) {
const fvRows = await db
.select({ userId: userProfileFieldValues.userId, fieldId: userProfileFieldValues.fieldId, value: userProfileFieldValues.value })
.from(userProfileFieldValues)
.where(inArray(userProfileFieldValues.userId, users.map(u => u.id)));
for (const row of fvRows) {
if (!fieldValuesMap.has(row.userId)) fieldValuesMap.set(row.userId, {});
fieldValuesMap.get(row.userId)![row.fieldId] = row.value;
}
}
let safeUsers = users.map(user => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
middleName: user.middleName,
position: user.position,
appRole: user.appRole,
isActive: user.isActive,
emailVerified: user.emailVerified,
lastLogin: user.lastLogin,
lastActivityAt: user.lastActivityAt,
createdAt: user.createdAt,
organizationalRoles: roleMembersMap.get(user.id) || [],
...(includeFieldValues ? { fieldValues: fieldValuesMap.get(user.id) || {} } : {}),
}));
Object.entries(filtersObj).forEach(([col, val]) => {
if (!val) return;
const valLower = (val as string).toLowerCase();
safeUsers = safeUsers.filter(u => getUserColVal(u, col).toLowerCase().includes(valLower));
});
if (sortColumn) {
safeUsers = safeUsers.sort((a, b) => {
const av = getUserColVal(a, sortColumn).toLowerCase();
const bv = getUserColVal(b, sortColumn).toLowerCase();
const cmp = av.localeCompare(bv, 'ru');
return sortDirection === 'asc' ? cmp : -cmp;
});
}
res.json({ success: true, users: safeUsers });
} catch (error) {
console.error('Get users list error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении пользователей' });
}
}
);
// Admin-only user list (kept for backward compatibility with admin integrations)
router.get('/api/users',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const users = await storage.getUsersByOrganization(req.organizationId!);
const orgRoles = await storage.getRoles(req.organizationId!);
let filtersObj: Record<string, string> = {};
try { filtersObj = req.query.filters ? JSON.parse(req.query.filters as string) : {}; } catch {}
const sortColumn = (req.query.sortColumn as string) || '';
const sortDirection = (req.query.sortDirection as string) === 'desc' ? 'desc' : 'asc';
// Build role membership map for all users
const roleMembersMap = new Map<number, Array<{ id: number; name: string }>>();
for (const r of orgRoles) {
for (const m of r.members) {
if (!roleMembersMap.has(m.userId)) roleMembersMap.set(m.userId, []);
roleMembersMap.get(m.userId)!.push({ id: r.id, name: r.name });
}
}
const getUserColVal = (u: any, col: string): string => {
if (col === 'name') return `${u.firstName} ${u.middleName || ''} ${u.lastName}`.trim();
if (col === 'email') return u.email || '';
if (col === 'role') return u.appRole || '';
if (col === 'status') return u.isActive ? 'Активен' : 'Неактивен';
if (col === 'position') return u.position || '';
return '';
};
let safeUsers = users.map(user => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
middleName: user.middleName,
position: user.position,
appRole: user.appRole,
isActive: user.isActive,
emailVerified: user.emailVerified,
lastLogin: user.lastLogin,
lastActivityAt: user.lastActivityAt,
createdAt: user.createdAt,
gpsAccess: user.gpsAccess,
financeAccess: user.financeAccess,
organizationalRoles: roleMembersMap.get(user.id) || [],
}));
Object.entries(filtersObj).forEach(([col, val]) => {
if (!val) return;
const valLower = (val as string).toLowerCase();
safeUsers = safeUsers.filter(u => getUserColVal(u, col).toLowerCase().includes(valLower));
});
if (sortColumn) {
safeUsers = safeUsers.sort((a, b) => {
const av = getUserColVal(a, sortColumn).toLowerCase();
const bv = getUserColVal(b, sortColumn).toLowerCase();
const cmp = av.localeCompare(bv, 'ru');
return sortDirection === 'asc' ? cmp : -cmp;
});
}
res.json({ success: true, users: safeUsers });
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении пользователей' });
}
});
// PATCH /api/users/:id/module-access — флаги доступа к модулям GPS/Финансы (только admin)
router.patch('/api/users/:id/module-access',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
if (req.user!.appRole !== 'admin') {
return res.status(403).json({ success: false, error: 'Требуются права администратора' });
}
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID пользователя' });
}
const { gpsAccess, financeAccess } = req.body ?? {};
const updates: { gpsAccess?: boolean; financeAccess?: boolean } = {};
if (gpsAccess !== undefined) {
if (typeof gpsAccess !== 'boolean') {
return res.status(400).json({ success: false, error: 'gpsAccess: boolean' });
}
updates.gpsAccess = gpsAccess;
}
if (financeAccess !== undefined) {
if (typeof financeAccess !== 'boolean') {
return res.status(400).json({ success: false, error: 'financeAccess: boolean' });
}
updates.financeAccess = financeAccess;
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ success: false, error: 'Передайте gpsAccess и/или financeAccess' });
}
// Tenant isolation: редактируем только пользователя своей организации
const target = await storage.getUser(userId);
if (!target || target.organizationId !== req.organizationId!) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const updated = await storage.updateUser(userId, updates);
res.json({
success: true,
user: { id: updated.id, gpsAccess: updated.gpsAccess, financeAccess: updated.financeAccess },
});
} catch (error) {
console.error('Update user module access error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении доступа к модулям' });
}
});
// Column values for users (autocomplete for filters)
router.get('/api/users/column-values',
async (req: AuthenticatedRequest, res) => {
try {
const column = (req.query.column as string) || '';
const search = ((req.query.search as string) || '').toLowerCase();
let context: Record<string, string> = {};
try { context = req.query.context ? JSON.parse(req.query.context as string) : {}; } catch {}
let users = await storage.getUsersByOrganization(req.organizationId!);
const getUserColVal = (u: any, col: string): string => {
if (col === 'name') return `${u.firstName} ${u.middleName || ''} ${u.lastName}`.trim();
if (col === 'email') return u.email || '';
if (col === 'role') return u.appRole || '';
if (col === 'status') return u.isActive ? 'Активен' : 'Неактивен';
if (col === 'position') return u.position || '';
return '';
};
Object.entries(context).forEach(([col, val]) => {
if (!val) return;
const vl = (val as string).toLowerCase();
users = users.filter(u => getUserColVal(u, col).toLowerCase().includes(vl));
});
const seen = new Set<string>();
const values: string[] = [];
for (const u of users) {
const val = getUserColVal(u, column);
if (!val || seen.has(val)) continue;
if (search && !val.toLowerCase().includes(search)) continue;
seen.add(val);
values.push(val);
if (values.length >= 20) break;
}
res.json({ values });
} catch (error) {
res.status(500).json({ error: 'Ошибка получения значений' });
}
}
);
router.post('/api/users',
requirePermission('users.manage'),
validateRequest(z.object({
email: z.string().email('Неверный формат email'),
firstName: z.string().min(1, 'Имя обязательно'),
lastName: z.string().min(1, 'Фамилия обязательна'),
middleName: z.string().optional(),
position: z.string().optional(),
appRole: z.string().min(1, 'Выберите роль доступа'),
organizationalRoleIds: z.array(z.number()).optional(),
sendInvite: z.boolean().default(true),
statusId: z.union([z.number(), z.null()]).optional(),
// Необязательный явный пароль; состав дополнительно проверяет hashPassword (400 с текстом правила)
password: z.string().min(8, 'Пароль должен содержать минимум 8 символов').optional()
})),
async (req: AuthenticatedRequest, res) => {
try {
const result = await authService.createUser(req.organizationId!, {
email: req.body.email,
firstName: req.body.firstName,
lastName: req.body.lastName,
middleName: req.body.middleName,
position: req.body.position,
appRole: req.body.appRole,
organizationalRoleIds: req.body.organizationalRoleIds,
sendInvite: req.body.sendInvite,
statusId: req.body.statusId ?? undefined,
password: req.body.password
});
if (result.success && result.user) {
res.status(201).json({
success: true,
message: result.message,
user: {
id: result.user.id,
email: result.user.email,
firstName: result.user.firstName,
lastName: result.user.lastName,
middleName: result.user.middleName,
position: result.user.position,
appRole: result.user.appRole,
isActive: result.user.isActive,
emailVerified: result.user.emailVerified,
createdAt: result.user.createdAt
}
});
} else {
res.status(400).json({ success: false, error: result.error });
}
} catch (error) {
console.error('Create user error:', error);
res.status(500).json({ success: false, error: 'Ошибка при создании пользователя' });
}
}
);
// Search users for @mentions (must be declared before /:id route)
router.get('/api/users/search',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const { q: query, limit = '10' } = req.query;
if (!query || typeof query !== 'string' || query.trim().length === 0) {
return res.status(400).json({ success: false, error: 'Поисковый запрос обязателен' });
}
if (!req.organizationId || typeof req.organizationId !== 'number' || isNaN(req.organizationId)) {
console.error('Invalid organizationId in search users:', req.organizationId);
return res.status(400).json({ success: false, error: 'Ошибка идентификации организации' });
}
const searchLimit = Math.min(parseInt(limit as string) || 10, 20);
const users = await storage.searchUsers(query.trim(), req.organizationId, searchLimit);
res.json({
success: true,
users: users.map(user => ({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
position: user.position
}))
});
} catch (error) {
console.error('Search users error:', error);
res.status(500).json({ success: false, error: 'Ошибка при поиске пользователей' });
}
}
);
// Unified search for @mentions - returns both users and bots
router.get('/api/mentions/search',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const { q: query, limit = '10' } = req.query;
// Пустой запрос допустим (голый '@' в чате): возвращаем первых пользователей
// организации и всех доступных ботов
const trimmedQuery = typeof query === 'string' ? query.trim() : '';
if (!req.organizationId || typeof req.organizationId !== 'number') {
return res.status(400).json({ success: false, error: 'Ошибка идентификации организации' });
}
const searchLimit = Math.min(parseInt(limit as string) || 10, 20);
const [users, allBots] = await Promise.all([
storage.searchUsers(trimmedQuery, req.organizationId, searchLimit),
storage.searchBots(trimmedQuery, req.organizationId, searchLimit)
]);
const currentUser = req.user!;
const bots = allBots.filter(bot => {
const policy = bot.accessPolicy;
if (!policy || policy.mode === 'all') return true;
if (policy.mode === 'roles') {
const roles = policy.roles ?? [];
if (roles.length === 0) return false;
return roles.includes(currentUser.appRole ?? '');
}
if (policy.mode === 'users') {
const userIds = policy.userIds ?? [];
if (userIds.length === 0) return false;
return userIds.includes(currentUser.id);
}
return false;
});
const suggestions = [
...users.map(user => ({
id: user.id,
type: 'user' as const,
name: `${user.firstName} ${user.middleName || ''} ${user.lastName}`.trim(),
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
position: user.position
})),
...bots.map(bot => ({
id: bot.id,
type: 'bot' as const,
name: bot.name,
firstName: bot.name,
lastName: '',
avatarUrl: bot.avatarUrl
}))
].slice(0, searchLimit);
res.json({ success: true, suggestions });
} catch (error) {
console.error('Search mentions error:', error);
res.status(500).json({ success: false, error: 'Ошибка при поиске' });
}
}
);
// === User offline subscriptions ===
const subscriptionSchema = z.object({
formId: z.number().int().positive(),
isActive: z.boolean().optional(),
includeRelatedTasks: z.boolean().optional(),
includeFiles: z.boolean().optional(),
});
async function canManageUserSubscriptions(req: AuthenticatedRequest, targetUserId: number): Promise<boolean> {
if (req.user!.id === targetUserId) return true;
return storage.hasAppRolePermission(req.user!.appRole, ['users.manage']);
}
router.get('/api/users/:userId/offline-subscriptions',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
if (isNaN(userId)) return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
if (!(await canManageUserSubscriptions(req, userId))) {
return res.status(403).json({ success: false, error: 'Нет прав на просмотр подписок' });
}
const subscriptions = await storage.getUserOfflineSubscriptions(userId, req.organizationId!);
res.json({ success: true, subscriptions });
} catch (error: any) {
console.error('Get offline subscriptions error:', error);
res.status(500).json({ success: false, error: 'Ошибка загрузки подписок' });
}
}
);
router.post('/api/users/:userId/offline-subscriptions',
authenticateToken,
tenantIsolation,
validateRequest(subscriptionSchema),
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
if (isNaN(userId)) return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
if (!(await canManageUserSubscriptions(req, userId))) {
return res.status(403).json({ success: false, error: 'Нет прав на управление подписками' });
}
const { formId, isActive, includeRelatedTasks, includeFiles } = req.body;
const existing = await storage.getUserOfflineSubscription(userId, formId);
const subscription = await storage.upsertUserOfflineSubscription({
organizationId: req.organizationId!,
userId,
formId,
isActive: isActive ?? existing?.isActive ?? true,
includeRelatedTasks: includeRelatedTasks ?? existing?.includeRelatedTasks ?? true,
includeFiles: includeFiles ?? existing?.includeFiles ?? false,
});
res.json({ success: true, subscription });
} catch (error: any) {
console.error('Upsert offline subscription error:', error);
res.status(500).json({ success: false, error: 'Ошибка сохранения подписки' });
}
}
);
router.delete('/api/users/:userId/offline-subscriptions/:formId',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
const formId = parseInt(req.params.formId);
if (isNaN(userId) || isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверные параметры' });
}
if (!(await canManageUserSubscriptions(req, userId))) {
return res.status(403).json({ success: false, error: 'Нет прав на управление подписками' });
}
await storage.deleteUserOfflineSubscription(userId, formId);
res.json({ success: true });
} catch (error: any) {
console.error('Delete offline subscription error:', error);
res.status(500).json({ success: false, error: 'Ошибка удаления подписки' });
}
}
);
// Активные сессии пользователя (устройства) — админ или сам пользователь
router.get('/api/users/:userId/sessions',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
if (isNaN(userId)) {
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
}
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const appRole = req.user?.appRole;
if (appRole !== 'admin' && req.user?.id !== userId) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const sessions = await storage.listUserSessions(userId);
const currentSid = (req as any).sessionId ?? null;
res.json({
success: true,
sessions: sessions.map(s => ({
id: s.id,
createdAt: s.createdAt,
expiresAt: s.expiresAt,
ipAddress: s.ipAddress,
userAgent: s.userAgent,
isCurrent: s.id === currentSid,
})),
});
} catch (error) {
console.error('List user sessions error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// Отзыв сессии (устройства) — своя или любая (админ)
router.delete('/api/auth/sessions/:id',
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({ success: false, error: 'Неверный ID сессии' });
}
const session = await storage.getUserSessionById(id);
if (!session) {
return res.status(404).json({ success: false, error: 'Сессия не найдена' });
}
const targetUser = await storage.getUser(session.userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Сессия не найдена' });
}
const appRole = req.user?.appRole;
if (appRole !== 'admin' && req.user?.id !== session.userId) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
await storage.revokeSessionById(id);
logAudit({
action: 'auth.session.revoked',
userId: req.user!.id,
organizationId: req.organizationId,
details: { sessionId: id, targetUserId: session.userId },
ip: getClientIp(req),
});
res.json({ success: true });
} catch (error) {
console.error('Revoke session error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
}