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,418 @@
import { storage } from '../storage';
import { hashPassword, verifyPassword, generateTempPassword } from '../utils/password';
import { generateTokens, generateVerificationToken, verifyRefreshToken, parseExpiryToSeconds, type TokenPair } from '../utils/jwt';
import { emailService } from './email.service';
import type { RegisterOrganization, LoginRequest, User, UserWithOrganization } from '@shared/schema';
import { db, withSuperAdmin } from '../db';
import { notificationService } from './notification.service';
import { conversations, conversationMembers } from '@shared/schema';
import { eq, and, desc, gte } from 'drizzle-orm';
import crypto from 'crypto';
import type { Request, Response } from 'express';
import { getClientIp } from '../utils/audit';
export async function addUserToDefaultGroup(userId: number, organizationId: number): Promise<void> {
const [defaultGroup] = await db
.select({ id: conversations.id })
.from(conversations)
.where(and(
eq(conversations.organizationId, organizationId),
eq(conversations.isDefault, true),
));
if (!defaultGroup) return;
await db
.insert(conversationMembers)
.values({ conversationId: defaultGroup.id, userId })
.onConflictDoNothing();
}
export interface AuthResponse {
success: boolean;
user?: UserWithOrganization;
tokens?: TokenPair;
remember?: boolean;
message?: string;
error?: string;
}
export const ACCESS_TOKEN_COOKIE = 'access_token';
export const REFRESH_TOKEN_COOKIE = 'refresh_token';
const SESSION_LIFETIME_DAYS = 30;
const REMEMBER_LIFETIME_DAYS = 90;
const RACE_TOLERANCE_MS = 300_000; // 5 минут — защита от rotation-гонок между фоновыми вкладками/PWA
function isSecureCookie(): boolean {
// In production we always serve over HTTPS behind Traefik.
return process.env.NODE_ENV === 'production';
}
export function setAuthCookies(res: Response, tokens: TokenPair, remember: boolean): void {
const accessMaxAge = parseExpiryToSeconds(process.env.JWT_ACCESS_EXPIRES || '15m') * 1000;
const refreshMaxAge = (remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS) * 24 * 60 * 60 * 1000;
res.cookie(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
httpOnly: true,
secure: isSecureCookie(),
sameSite: 'strict',
maxAge: accessMaxAge,
path: '/',
});
res.cookie(REFRESH_TOKEN_COOKIE, tokens.refreshToken, {
httpOnly: true,
secure: isSecureCookie(),
sameSite: 'strict',
maxAge: refreshMaxAge,
path: '/api/auth',
});
}
export function clearAuthCookies(res: Response): void {
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/api/auth' });
}
export class AuthService {
async registerOrganization(data: RegisterOrganization): Promise<AuthResponse> {
return withSuperAdmin(async () => {
try {
const existingOrg = await storage.getOrganizationBySlug(data.organizationSlug);
if (existingOrg) {
return { success: false, error: 'Организация с таким кодом уже существует' };
}
const existingUser = await storage.getUserByEmail(data.adminEmail);
if (existingUser) {
return { success: false, error: 'Пользователь с таким email уже существует' };
}
const organization = await storage.createOrganization({
name: data.organizationName,
slug: data.organizationSlug,
displayName: data.organizationName,
isActive: true
});
// Create default OpenStreetMap service for the new organization
await storage.createExternalService({
organizationId: organization.id,
serviceType: 'openstreetmap',
serviceName: 'OpenStreetMap (Nominatim)',
apiKey: null,
secretKey: null,
config: null,
isActive: true,
}).catch((err: unknown) => {
console.error('[Auth] Failed to create default OpenStreetMap service:', err);
});
const passwordHash = await hashPassword(data.adminPassword);
const adminUser = await storage.createUser({
organizationId: organization.id,
email: data.adminEmail,
passwordHash,
firstName: data.adminFirstName,
lastName: data.adminLastName,
middleName: (data as any).adminMiddleName,
appRole: 'admin',
isActive: true,
emailVerified: true,
});
notificationService.createDefaultSubscriptions(adminUser.id, organization.id).catch(err =>
console.error('[Auth] Failed to create default notification subscriptions:', err)
);
const [defaultConv] = await db
.insert(conversations)
.values({
organizationId: organization.id,
type: 'group',
name: 'Наша Фирма',
createdBy: adminUser.id,
isDefault: true,
})
.returning();
await db.insert(conversationMembers).values({ conversationId: defaultConv.id, userId: adminUser.id });
return { success: true, message: 'Организация создана успешно! Теперь вы можете войти в систему.' };
} catch (error) {
console.error('Registration error:', error);
return { success: false, error: 'Ошибка при создании организации' };
}
});
}
private async createSession(
user: UserWithOrganization,
remember: boolean,
req?: Request
): Promise<TokenPair> {
const tokenLifetimeDays = remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS;
const familyId = crypto.randomUUID();
const now = Date.now();
const familyCreatedAt = new Date(now);
const expiresAt = new Date(now + tokenLifetimeDays * 24 * 60 * 60 * 1000);
const tokens = generateTokens({
userId: user.id,
organizationId: user.organizationId,
appRole: user.appRole
}, remember, familyId);
await storage.createUserSession({
userId: user.id,
refreshToken: tokens.refreshToken,
familyId,
familyCreatedAt,
expiresAt,
remember,
ipAddress: req ? getClientIp(req) : null,
userAgent: req?.headers['user-agent'] ?? null,
});
return tokens;
}
async login(data: LoginRequest, req?: Request): Promise<AuthResponse> {
return withSuperAdmin(async () => {
try {
let user: UserWithOrganization | undefined;
if (data.organizationSlug) {
user = await storage.getUserByEmailAndSlug(data.email, data.organizationSlug);
} else {
const foundUser = await storage.getUserByEmail(data.email);
if (foundUser) {
user = await storage.getUserWithOrganization(foundUser.id);
}
}
if (!user) {
return { success: false, error: 'Неверный email или пароль' };
}
if (!user.isActive) {
return { success: false, error: 'Аккаунт заблокирован' };
}
if (user.organization && !user.organization.isActive) {
return { success: false, error: 'Доступ организации заблокирован' };
}
const isValidPassword = await verifyPassword(data.password, user.passwordHash);
if (!isValidPassword) {
return { success: false, error: 'Неверный email или пароль' };
}
const remember = data.remember ?? false;
const tokens = await this.createSession(user, remember, req);
await storage.updateUser(user.id, { lastLogin: new Date() });
const permissions = await storage.getAppRolePermissions(user.appRole);
return { success: true, user: { ...user, appRole: user.appRole, permissions } as any, tokens };
} catch (error) {
console.error('Login error:', error);
return { success: false, error: 'Ошибка при входе в систему' };
}
});
}
async createUser(organizationId: number, userData: {
email: string;
firstName: string;
lastName: string;
middleName?: string;
position?: string;
appRole: string;
organizationalRoleIds?: number[];
sendInvite: boolean;
password?: string;
statusId?: number;
customFields?: Array<{ fieldId: number; value: string | null }>;
}): Promise<{ success: boolean; user?: User; message?: string; error?: string }> {
return withSuperAdmin(async () => {
try {
const existingUser = await storage.getUserByEmail(userData.email, organizationId);
if (existingUser) {
return { success: false, error: 'Пользователь с таким email уже существует в организации' };
}
const tempPassword = userData.password ?? generateTempPassword();
const passwordHash = await hashPassword(tempPassword);
const user = await storage.createUser({
organizationId,
email: userData.email,
passwordHash,
firstName: userData.firstName,
lastName: userData.lastName,
middleName: userData.middleName,
position: userData.position,
appRole: userData.appRole,
statusId: userData.statusId,
isActive: true,
emailVerified: true
});
// Assign organizational roles if provided
if (userData.organizationalRoleIds && userData.organizationalRoleIds.length > 0) {
await storage.setUserOrganizationalRoles(user.id, userData.organizationalRoleIds);
}
// Set custom fields if provided
if (userData.customFields && userData.customFields.length > 0) {
await storage.setUserCustomValues(user.id, userData.customFields, organizationId);
}
await addUserToDefaultGroup(user.id, organizationId);
notificationService.createDefaultSubscriptions(user.id, organizationId).catch(err =>
console.error('[Auth] Failed to create default notification subscriptions:', err)
);
return {
success: true,
user,
message: userData.password
? 'Пользователь создан с указанным паролем'
: (userData.sendInvite
? `Пользователь создан. Временный пароль: ${tempPassword}. Сообщите его новому пользователю.`
: 'Пользователь создан')
};
} catch (error) {
console.error('Create user error:', error);
return { success: false, error: 'Ошибка при создании пользователя' };
}
});
}
async refreshToken(refreshToken: string): Promise<AuthResponse> {
return withSuperAdmin(async () => {
try {
const session = await storage.getUserSessionByToken(refreshToken);
// Token not found as current — maybe it was already rotated (race or reuse).
if (!session) {
const rotatedSession = await storage.getUserSessionByParentToken(refreshToken);
if (rotatedSession && !rotatedSession.isRevoked) {
const rotatedAt = rotatedSession.createdAt ? new Date(rotatedSession.createdAt).getTime() : 0;
// Within grace period: return the current active tokens of the same family.
if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) {
const activeSession = await storage.getActiveSessionInFamily(rotatedSession.familyId);
if (activeSession) {
const activeUser = await storage.getUserWithOrganization(activeSession.userId);
if (activeUser && activeUser.isActive) {
const remember = activeSession.remember ?? false;
const accessToken = generateTokens({
userId: activeUser.id,
organizationId: activeUser.organizationId,
appRole: activeUser.appRole
}, remember, activeSession.familyId).accessToken;
return {
success: true,
user: { ...activeUser, appRole: activeUser.appRole, permissions: await storage.getAppRolePermissions(activeUser.appRole) } as any,
remember,
tokens: {
accessToken,
refreshToken: activeSession.refreshToken,
expiresIn: parseExpiryToSeconds(process.env.JWT_ACCESS_EXPIRES || '15m'),
familyId: activeSession.familyId,
}
};
}
}
}
// Reuse detected: revoke the whole family.
console.warn(`[Auth] Refresh token reuse detected for family ${rotatedSession.familyId}, rotating session ${rotatedSession.id}`);
await storage.revokeSessionFamily(rotatedSession.familyId);
}
console.warn('[Auth] Refresh denied: token not found as current or rotated');
return { success: false, error: 'Недействительный или истекший токен' };
}
if (session.isRevoked) {
console.warn(`[Auth] Refresh denied: session ${session.id} is revoked (family ${session.familyId})`);
return { success: false, error: 'Недействительный или истекший токен' };
}
if (session.isReplaced) {
console.warn(`[Auth] Refresh denied: session ${session.id} is already replaced (family ${session.familyId})`);
return { success: false, error: 'Недействительный или истекший токен' };
}
if (session.expiresAt < new Date()) {
console.warn(`[Auth] Refresh denied: session ${session.id} expired at ${session.expiresAt.toISOString()}`);
return { success: false, error: 'Недействительный или истекший токен' };
}
const decoded = verifyRefreshToken(refreshToken);
if (decoded.userId !== session.userId) {
console.warn(`[Auth] Refresh denied: token userId ${decoded.userId} does not match session userId ${session.userId}`);
await storage.revokeSessionFamily(session.familyId);
return { success: false, error: 'Недействительный токен' };
}
const user = await storage.getUserWithOrganization(session.userId);
if (!user || !user.isActive) {
console.warn(`[Auth] Refresh denied: user ${session.userId} not found or inactive`);
return { success: false, error: 'Пользователь не найден или заблокирован' };
}
if (user.organization && !user.organization.isActive) {
console.warn(`[Auth] Refresh denied: organization ${user.organizationId} inactive`);
return { success: false, error: 'Доступ организации заблокирован' };
}
const remember = session.remember ?? false;
const tokenLifetimeDays = remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS;
const msLifetime = tokenLifetimeDays * 24 * 60 * 60 * 1000;
const familyCreatedAt = new Date(session.familyCreatedAt).getTime();
const maxExpiresAt = new Date(familyCreatedAt + msLifetime);
const proposedExpiresAt = new Date(Date.now() + msLifetime);
const expiresAt = proposedExpiresAt < maxExpiresAt ? proposedExpiresAt : maxExpiresAt;
const tokens = generateTokens({
userId: user.id,
organizationId: user.organizationId,
appRole: user.appRole
}, remember, session.familyId);
await storage.markSessionReplaced(session.refreshToken, tokens.refreshToken);
await storage.createUserSession({
userId: user.id,
refreshToken: tokens.refreshToken,
familyId: session.familyId,
familyCreatedAt: session.familyCreatedAt,
parentRefreshToken: session.refreshToken,
expiresAt,
remember,
});
const permissions = await storage.getAppRolePermissions(user.appRole);
return { success: true, user: { ...user, appRole: user.appRole, permissions } as any, remember, tokens };
} catch (error) {
console.error('[Auth] Refresh token error:', error);
return { success: false, error: 'Ошибка при обновлении токена' };
}
});
}
async logout(refreshToken: string): Promise<void> {
try {
await withSuperAdmin(() => storage.revokeSession(refreshToken));
} catch (error) {
console.error('Logout error:', error);
}
}
async invalidateAllUserSessions(userId: number): Promise<void> {
try {
await withSuperAdmin(() => storage.revokeAllUserSessions(userId));
} catch (error) {
console.error('Invalidate all sessions error:', error);
}
}
}
export const authService = new AuthService();