Files
app/src/routes/admin/profile.ts
BOHA d7c7fbad88 fix: security, validation, and data integrity fixes across 53 files
- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:40:38 +02:00

100 lines
3.3 KiB
TypeScript

import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth } from "../../middleware/auth";
import { success, error } from "../../utils/response";
import bcrypt from "bcryptjs";
import { config } from "../../config/env";
import { logAudit } from "../../services/audit";
import { parseBody } from "../../schemas/common";
import { UpdateProfileSchema } from "../../schemas/profile.schema";
export default async function profileRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const user = await prisma.users.findUnique({
where: { id: request.authData!.userId },
select: {
id: true,
username: true,
email: true,
first_name: true,
last_name: true,
totp_enabled: true,
last_login: true,
password_changed_at: true,
roles: { select: { id: true, name: true, display_name: true } },
},
});
if (!user) return error(reply, "Uživatel nenalezen", 404);
return success(reply, user);
});
fastify.put("/", { preHandler: requireAuth }, async (request, reply) => {
const parsed = parseBody(UpdateProfileSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const userId = request.authData!.userId;
const data: Record<string, unknown> = {};
if (body.email) {
data.email = String(body.email).trim();
}
if (body.first_name) data.first_name = String(body.first_name);
if (body.last_name) data.last_name = String(body.last_name);
if (body.current_password && body.new_password) {
const user = await prisma.users.findUnique({ where: { id: userId } });
if (!user) return error(reply, "Uživatel nenalezen", 404);
const valid = await bcrypt.compare(
String(body.current_password),
user.password_hash,
);
if (!valid) return error(reply, "Nesprávné aktuální heslo", 400);
data.password_hash = await bcrypt.hash(
String(body.new_password),
config.security.bcryptCost,
);
data.password_changed_at = new Date();
}
// Wrap email uniqueness check and update in a transaction to prevent race condition
try {
await prisma.$transaction(async (tx) => {
if (data.email) {
const existing = await tx.users.findFirst({
where: { email: String(data.email), id: { not: userId } },
});
if (existing) throw new Error("EMAIL_EXISTS");
}
await tx.users.update({ where: { id: userId }, data });
});
} catch (e) {
if (e instanceof Error && e.message === "EMAIL_EXISTS") {
return error(reply, "E-mail již existuje", 409);
}
throw e;
}
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "user",
entityId: userId,
description: data.password_hash ? "Změna hesla" : "Aktualizace profilu",
});
if (body.current_password && body.new_password) {
await prisma.refresh_tokens.updateMany({
where: { user_id: userId, replaced_at: null },
data: { replaced_at: new Date() },
});
}
return success(reply, null, 200, "Profil aktualizován");
});
}