fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,411 @@
import { Router } from "express";
import { z } from 'zod';
import crypto from 'crypto';
import { storage } from "../storage";
import { authService, addUserToDefaultGroup, setAuthCookies, clearAuthCookies } from "../services/auth.service";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { registerOrganizationSchema, loginSchema, registerByInvitationSchema } from "@shared/schema";
import { hashPassword, verifyPassword } from "../utils/password";
import { generateTokens } from "../utils/jwt";
import { emailService } from "../services/email.service";
import { notificationService } from "../services/notification.service";
import { authLimiter, refreshLimiter } from "./shared";
import { logAudit, getClientIp } from "../utils/audit";
export function registerAuthCoreRoutes(router: Router): void {
/**
* @swagger
* /api/auth/register-organization:
* post:
* tags: [Auth]
* summary: Регистрация организации
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RegisterOrganizationInput'
* responses:
* 201:
* description: Организация создана, письмо с подтверждением отправлено
* 400:
* description: Ошибка валидации или slug уже занят
*/
router.post('/api/auth/register-organization',
authLimiter,
validateRequest(registerOrganizationSchema),
async (req, res) => {
try {
const result = await authService.registerOrganization(req.body);
if (result.success) {
res.status(201).json({ success: true, message: result.message });
} else {
res.status(400).json({ success: false, error: result.error });
}
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
/**
* @swagger
* /api/auth/login:
* post:
* tags: [Auth]
* summary: Вход в систему
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginInput'
* responses:
* 200:
* description: Успешный вход
* 401:
* description: Неверные учётные данные
*/
router.post('/api/auth/login',
authLimiter,
validateRequest(loginSchema),
async (req, res) => {
try {
const result = await authService.login(req.body, req);
if (result.success && result.user && result.tokens) {
logAudit({
action: 'auth.login.success',
userId: result.user.id,
organizationId: result.user.organization.id,
details: { email: result.user.email },
ip: getClientIp(req),
userAgent: req.headers['user-agent'] ?? null,
});
setAuthCookies(res, result.tokens, req.body.remember ?? false);
res.json({
success: true,
user: {
id: result.user.id,
email: result.user.email,
firstName: result.user.firstName,
lastName: result.user.lastName,
middleName: result.user.middleName,
appRole: result.user.appRole,
permissions: (result.user as any).permissions || [],
organization: {
id: result.user.organization.id,
name: result.user.organization.name,
slug: result.user.organization.slug,
displayName: result.user.organization.displayName
}
},
tokens: result.tokens
});
} else {
logAudit({
action: 'auth.login.failed',
details: { email: req.body?.email, reason: result.error },
ip: getClientIp(req),
userAgent: req.headers['user-agent'] ?? null,
});
res.status(401).json({ success: false, error: result.error });
}
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.post('/api/auth/refresh', refreshLimiter, async (req, res) => {
try {
const refreshToken = req.cookies?.refresh_token || req.body?.refreshToken;
if (!refreshToken) {
return res.status(400).json({ success: false, error: 'Refresh token отсутствует' });
}
const result = await authService.refreshToken(refreshToken);
if (result.success && result.tokens && result.user) {
// Prefer the session's remember flag; fall back to the request body for legacy/native clients.
const remember = result.remember ?? req.body?.remember ?? false;
setAuthCookies(res, result.tokens, remember);
res.json({
success: true,
tokens: result.tokens,
user: {
id: result.user.id,
email: result.user.email,
firstName: result.user.firstName,
lastName: result.user.lastName,
middleName: result.user.middleName,
appRole: result.user.appRole,
permissions: (result.user as any).permissions || [],
organization: {
id: result.user.organization.id,
name: result.user.organization.name,
slug: result.user.organization.slug,
displayName: result.user.organization.displayName
}
}
});
} else {
clearAuthCookies(res);
res.status(401).json({ success: false, error: result.error });
}
} catch (error) {
console.error('Refresh token error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
router.post('/api/auth/logout', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const refreshToken = req.cookies?.refresh_token || req.body?.refreshToken;
if (refreshToken) {
await authService.logout(refreshToken);
}
clearAuthCookies(res);
logAudit({
action: 'auth.logout',
userId: req.user?.id ?? null,
organizationId: req.organizationId ?? null,
ip: getClientIp(req),
userAgent: req.headers['user-agent'] ?? null,
});
res.json({ success: true, message: 'Успешный выход' });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ success: false, error: 'Ошибка при выходе' });
}
});
// GET /api/auth/me — return current authenticated user
router.get('/api/auth/me', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ success: false, error: 'Пользователь не найден' });
}
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
middleName: user.middleName,
appRole: user.appRole,
permissions: (user as any).permissions || [],
organization: user.organization ? {
id: user.organization.id,
name: user.organization.name,
slug: user.organization.slug,
displayName: user.organization.displayName
} : null
}
});
} catch (error) {
console.error('Get me error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
/**
* @swagger
* /api/auth/register-by-invitation:
* post:
* tags: [Auth, Invitations]
* summary: Регистрация по приглашению
* security: []
*/
router.post('/api/auth/register-by-invitation',
authLimiter,
validateRequest(registerByInvitationSchema),
async (req, res) => {
try {
const { token, firstName, lastName, middleName, password, position } = req.body;
const invitation = await storage.getInvitationByToken(token);
if (!invitation) {
return res.status(404).json({ success: false, error: 'Приглашение не найдено' });
}
if (invitation.usedAt) {
return res.status(400).json({ success: false, error: 'Приглашение уже использовано' });
}
if (new Date(invitation.expiresAt) < new Date()) {
return res.status(400).json({ success: false, error: 'Срок действия приглашения истёк' });
}
const existingUser = await storage.getUserByEmail(invitation.email, invitation.organizationId);
if (existingUser) {
return res.status(400).json({ success: false, error: 'Пользователь с таким email уже существует' });
}
const passwordHash = await hashPassword(password);
const user = await storage.createUser({
organizationId: invitation.organizationId,
email: invitation.email,
passwordHash,
firstName,
lastName,
middleName,
position: position || null,
appRole: 'user',
isActive: true,
emailVerified: true,
});
await addUserToDefaultGroup(user.id, invitation.organizationId);
notificationService.createDefaultSubscriptions(user.id, invitation.organizationId).catch(err =>
console.error('[Auth] Failed to create default notification subscriptions:', err)
);
await storage.markInvitationAsUsed(token);
res.status(201).json({
success: true,
message: 'Регистрация успешна! Теперь вы можете войти в систему.'
});
} catch (error) {
console.error('Register by invitation error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// POST /api/auth/forgot-password
router.post('/api/auth/forgot-password', authLimiter, async (req, res) => {
try {
const { email, returnTo } = req.body;
if (!email) {
return res.status(400).json({ success: false, error: 'Необходим email' });
}
const user = await storage.getUserByEmail(email);
if (!user) {
return res.json({ success: true });
}
const organization = await storage.getOrganization(user.organizationId);
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 60 * 60 * 1000);
await storage.updateUser(user.id, {
resetPasswordToken: token,
resetPasswordExpires: expires,
});
const safeReturnTo = returnTo && typeof returnTo === 'string' && returnTo.startsWith('/') && !returnTo.startsWith('/login')
? returnTo
: undefined;
await emailService.sendPasswordResetEmail(
user.email,
user.firstName,
token,
organization?.displayName || organization?.name || 'Организация',
safeReturnTo
);
return res.json({ success: true });
} catch (error) {
console.error('Forgot password error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/auth/reset-password
router.post('/api/auth/reset-password', authLimiter, async (req, res) => {
try {
const { token, password } = req.body;
if (!token || !password) {
return res.status(400).json({ success: false, error: 'Необходимы token и password' });
}
if (password.length < 8) {
return res.status(400).json({ success: false, error: 'Пароль должен быть не менее 8 символов' });
}
const user = await storage.getUserByResetPasswordToken(token);
if (!user) {
return res.status(400).json({ success: false, error: 'Неверная или устаревшая ссылка для сброса пароля' });
}
if (!user.resetPasswordExpires || new Date() > new Date(user.resetPasswordExpires)) {
await storage.updateUser(user.id, { resetPasswordToken: null, resetPasswordExpires: null });
return res.status(400).json({ success: false, error: 'Срок действия ссылки истёк. Запросите новую.' });
}
const passwordHash = await hashPassword(password);
await storage.updateUser(user.id, {
passwordHash,
resetPasswordToken: null,
resetPasswordExpires: null,
});
// При сбросе пароля инвалидируем все сессии пользователя — украденные
// refresh token'ы больше не смогут продлевать доступ.
await authService.invalidateAllUserSessions(user.id);
clearAuthCookies(res);
return res.json({ success: true });
} catch (error) {
console.error('Reset password error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/auth/change-password — self-service password change
router.post('/api/auth/change-password',
authenticateToken,
validateRequest(z.object({
currentPassword: z.string().min(1, 'Текущий пароль обязателен'),
newPassword: z.string().min(1, 'Новый пароль обязателен')
})),
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const user = await storage.getUser(userId);
if (!user) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const isCurrentValid = await verifyPassword(req.body.currentPassword, user.passwordHash);
if (!isCurrentValid) {
return res.status(400).json({ success: false, error: 'Неверный текущий пароль' });
}
const passwordHash = await hashPassword(req.body.newPassword);
await storage.updateUser(userId, {
passwordHash,
updatedAt: new Date()
});
await authService.invalidateAllUserSessions(userId);
clearAuthCookies(res);
logAudit({
action: 'auth.password.changed',
userId,
organizationId: user.organizationId,
details: { self: true },
ip: getClientIp(req),
userAgent: req.headers['user-agent'] ?? null,
});
return res.json({ success: true, message: 'Пароль изменен' });
} catch (error) {
console.error('Change password error:', error);
if (error instanceof Error && error.message.includes('Пароль должен')) {
return res.status(400).json({ success: false, error: error.message });
}
return res.status(500).json({ success: false, error: 'Ошибка при изменении пароля' });
}
}
);
}