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>
This commit is contained in:
@@ -144,19 +144,19 @@ export default async function attendanceRoutes(
|
||||
|
||||
// --- action=attendance_users: users with attendance.record permission ---
|
||||
if (action === "attendance_users") {
|
||||
if (
|
||||
!authData.permissions.includes("attendance.admin") &&
|
||||
!authData.permissions.includes("attendance.view") &&
|
||||
!authData.permissions.includes("attendance.record")
|
||||
) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
const users = await prisma.users.findMany({
|
||||
where: {
|
||||
is_active: true,
|
||||
roles: {
|
||||
is: {
|
||||
OR: [
|
||||
{ name: "admin" },
|
||||
{
|
||||
role_permissions: {
|
||||
some: { permissions: { name: "attendance.record" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
role_permissions: {
|
||||
some: { permissions: { name: "attendance.record" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -182,6 +182,12 @@ export default async function attendanceRoutes(
|
||||
|
||||
// --- action=project_logs: get project logs for a specific attendance record ---
|
||||
if (action === "project_logs") {
|
||||
if (
|
||||
!authData.permissions.includes("attendance.view") &&
|
||||
!authData.permissions.includes("attendance.record")
|
||||
) {
|
||||
return error(reply, "Nedostatečná oprávnění", 403);
|
||||
}
|
||||
const attendanceId = Number(query.attendance_id);
|
||||
if (!attendanceId) return error(reply, "Missing attendance_id", 400);
|
||||
const data = await attendanceService.getProjectLogs(attendanceId);
|
||||
@@ -411,7 +417,7 @@ export default async function attendanceRoutes(
|
||||
// PUT /api/admin/attendance/:id
|
||||
fastify.put<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
{ preHandler: requirePermission("attendance.edit") },
|
||||
async (request, reply) => {
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
|
||||
@@ -95,7 +95,7 @@ export default async function authRoutes(
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 20,
|
||||
max: 5,
|
||||
timeWindow: "1 minute",
|
||||
},
|
||||
},
|
||||
@@ -104,10 +104,8 @@ export default async function authRoutes(
|
||||
async (request, reply) => {
|
||||
const parsed = parseBody(TotpVerifySchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { login_token, totp_code } = parsed.data;
|
||||
const rawBody = request.body as unknown as Record<string, unknown>;
|
||||
const rememberMe =
|
||||
rawBody.remember_me === true || rawBody.remember_me === "true";
|
||||
const { login_token, totp_code, remember_me } = parsed.data;
|
||||
const rememberMe = remember_me ?? false;
|
||||
|
||||
const tokenHash = crypto
|
||||
.createHash("sha256")
|
||||
@@ -130,8 +128,6 @@ export default async function authRoutes(
|
||||
const storedTokenId = Number(storedToken.id);
|
||||
const storedUserId = Number(storedToken.user_id);
|
||||
|
||||
await tx.totp_login_tokens.delete({ where: { id: storedTokenId } });
|
||||
|
||||
const user = await tx.users.findUnique({
|
||||
where: { id: storedUserId },
|
||||
include: { roles: true },
|
||||
@@ -141,11 +137,15 @@ export default async function authRoutes(
|
||||
return { error: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return { error: "Účet je deaktivován", status: 401 };
|
||||
}
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
return { error: "Účet je dočasně uzamčen", status: 429 };
|
||||
}
|
||||
|
||||
return { user };
|
||||
return { user, storedTokenId };
|
||||
});
|
||||
|
||||
if ("error" in totpResult) {
|
||||
@@ -183,6 +183,11 @@ export default async function authRoutes(
|
||||
return error(reply, "TOTP kód již byl použit", 401);
|
||||
}
|
||||
|
||||
// TOTP verified successfully — now consume the login token
|
||||
await prisma.totp_login_tokens.delete({
|
||||
where: { id: totpResult.storedTokenId },
|
||||
});
|
||||
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
@@ -234,6 +239,16 @@ export default async function authRoutes(
|
||||
});
|
||||
|
||||
setRefreshCookie(reply, refreshTokenRaw, rememberMe);
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: authData,
|
||||
action: "login_totp",
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
description: `TOTP přihlášení uživatele ${user.username}`,
|
||||
});
|
||||
|
||||
return success(reply, { access_token: accessToken, user: authData });
|
||||
},
|
||||
);
|
||||
@@ -278,7 +293,7 @@ export default async function authRoutes(
|
||||
);
|
||||
|
||||
// POST /api/admin/logout
|
||||
fastify.post("/logout", async (request, reply) => {
|
||||
fastify.post("/logout", { bodyLimit: 10240 }, async (request, reply) => {
|
||||
const refreshTokenRaw = request.cookies.refresh_token;
|
||||
if (refreshTokenRaw) {
|
||||
await logout(refreshTokenRaw);
|
||||
|
||||
@@ -135,6 +135,7 @@ export default async function companySettingsRoutes(
|
||||
);
|
||||
|
||||
fastify.get("/", { preHandler: requireAuth }, async (_request, reply) => {
|
||||
// Use upsert to avoid race condition between findFirst and create
|
||||
let settings = await prisma.company_settings.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -176,50 +177,100 @@ export default async function companySettingsRoutes(
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
settings = await prisma.company_settings.create({
|
||||
data: {
|
||||
company_name: "",
|
||||
quotation_prefix: "N",
|
||||
default_currency: "EUR",
|
||||
default_vat_rate: 21.0,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
company_name: true,
|
||||
street: true,
|
||||
city: true,
|
||||
postal_code: true,
|
||||
country: true,
|
||||
company_id: true,
|
||||
vat_id: true,
|
||||
custom_fields: true,
|
||||
quotation_prefix: true,
|
||||
default_currency: true,
|
||||
default_vat_rate: true,
|
||||
uuid: true,
|
||||
modified_at: true,
|
||||
is_deleted: true,
|
||||
sync_version: true,
|
||||
order_type_code: true,
|
||||
invoice_type_code: true,
|
||||
require_2fa: true,
|
||||
break_threshold_hours: true,
|
||||
break_duration_short: true,
|
||||
break_duration_long: true,
|
||||
clock_rounding_minutes: true,
|
||||
invoice_alert_email: true,
|
||||
leave_notify_email: true,
|
||||
max_login_attempts: true,
|
||||
lockout_minutes: true,
|
||||
max_requests_per_minute: true,
|
||||
available_vat_rates: true,
|
||||
available_currencies: true,
|
||||
smtp_from: true,
|
||||
smtp_from_name: true,
|
||||
offer_number_pattern: true,
|
||||
order_number_pattern: true,
|
||||
invoice_number_pattern: true,
|
||||
},
|
||||
// Wrap create in a transaction to handle race condition:
|
||||
// another request may have created settings between findFirst and create
|
||||
settings = await prisma.$transaction(async (tx) => {
|
||||
// Double-check inside transaction
|
||||
const existing = await tx.company_settings.findFirst({
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
return tx.company_settings.findUnique({
|
||||
where: { id: existing.id },
|
||||
select: {
|
||||
id: true,
|
||||
company_name: true,
|
||||
street: true,
|
||||
city: true,
|
||||
postal_code: true,
|
||||
country: true,
|
||||
company_id: true,
|
||||
vat_id: true,
|
||||
custom_fields: true,
|
||||
quotation_prefix: true,
|
||||
default_currency: true,
|
||||
default_vat_rate: true,
|
||||
uuid: true,
|
||||
modified_at: true,
|
||||
is_deleted: true,
|
||||
sync_version: true,
|
||||
order_type_code: true,
|
||||
invoice_type_code: true,
|
||||
require_2fa: true,
|
||||
break_threshold_hours: true,
|
||||
break_duration_short: true,
|
||||
break_duration_long: true,
|
||||
clock_rounding_minutes: true,
|
||||
invoice_alert_email: true,
|
||||
leave_notify_email: true,
|
||||
max_login_attempts: true,
|
||||
lockout_minutes: true,
|
||||
max_requests_per_minute: true,
|
||||
available_vat_rates: true,
|
||||
available_currencies: true,
|
||||
smtp_from: true,
|
||||
smtp_from_name: true,
|
||||
offer_number_pattern: true,
|
||||
order_number_pattern: true,
|
||||
invoice_number_pattern: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
return tx.company_settings.create({
|
||||
data: {
|
||||
company_name: "",
|
||||
quotation_prefix: "N",
|
||||
default_currency: "EUR",
|
||||
default_vat_rate: 21.0,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
company_name: true,
|
||||
street: true,
|
||||
city: true,
|
||||
postal_code: true,
|
||||
country: true,
|
||||
company_id: true,
|
||||
vat_id: true,
|
||||
custom_fields: true,
|
||||
quotation_prefix: true,
|
||||
default_currency: true,
|
||||
default_vat_rate: true,
|
||||
uuid: true,
|
||||
modified_at: true,
|
||||
is_deleted: true,
|
||||
sync_version: true,
|
||||
order_type_code: true,
|
||||
invoice_type_code: true,
|
||||
require_2fa: true,
|
||||
break_threshold_hours: true,
|
||||
break_duration_short: true,
|
||||
break_duration_long: true,
|
||||
clock_rounding_minutes: true,
|
||||
invoice_alert_email: true,
|
||||
leave_notify_email: true,
|
||||
max_login_attempts: true,
|
||||
lockout_minutes: true,
|
||||
max_requests_per_minute: true,
|
||||
available_vat_rates: true,
|
||||
available_currencies: true,
|
||||
smtp_from: true,
|
||||
smtp_from_name: true,
|
||||
offer_number_pattern: true,
|
||||
order_number_pattern: true,
|
||||
invoice_number_pattern: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
if (!settings) return error(reply, "Nastavení nenalezeno", 500);
|
||||
@@ -429,7 +480,7 @@ export default async function companySettingsRoutes(
|
||||
: existingOrder,
|
||||
);
|
||||
}
|
||||
data.sync_version = (existing.sync_version ?? 0) + 1;
|
||||
data.sync_version = { increment: 1 };
|
||||
|
||||
await prisma.company_settings.update({
|
||||
where: { id: existing.id },
|
||||
|
||||
@@ -180,9 +180,9 @@ export default async function dashboardRoutes(
|
||||
// Invoices — only for invoices.view
|
||||
if (has("invoices.view")) {
|
||||
// $queryRaw template literal interpolation with Date objects fails on
|
||||
// MySQL when Date.toJSON is overridden — pass strings instead.
|
||||
const monthStartStr = monthStart.toJSON();
|
||||
const monthEndStr = monthEnd.toJSON();
|
||||
// MySQL when Date.toJSON is overridden — pass explicit date strings instead.
|
||||
const monthStartStr = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
const monthEndStr = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
|
||||
const [unpaidCount, revenueAgg] = await Promise.all([
|
||||
prisma.invoices.count({ where: { status: "issued" } }),
|
||||
|
||||
@@ -26,10 +26,16 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
||||
export default async function invoicesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
// Auto-update overdue invoices on GET requests only (matches PHP behavior)
|
||||
// Auto-update overdue invoices on GET requests, throttled to once per hour
|
||||
let lastOverdueCheck = 0;
|
||||
const OVERDUE_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
fastify.addHook("onRequest", async (request) => {
|
||||
if (request.method !== "GET") return;
|
||||
await markOverdueInvoices();
|
||||
if (Date.now() - lastOverdueCheck > OVERDUE_CHECK_INTERVAL) {
|
||||
lastOverdueCheck = Date.now();
|
||||
await markOverdueInvoices();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/invoices
|
||||
@@ -226,12 +232,10 @@ export default async function invoicesRoutes(
|
||||
const file = nasFinancialsManager.readIssuedInvoice(relPath);
|
||||
if (!file) return error(reply, "PDF soubor nenalezen", 404);
|
||||
|
||||
const safeName = invoice.invoice_number.replace(/[\r\n"]/g, "");
|
||||
return reply
|
||||
.type("application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${invoice.invoice_number}.pdf"`,
|
||||
)
|
||||
.header("Content-Disposition", `inline; filename="${safeName}.pdf"`)
|
||||
.send(file.data);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -241,71 +241,84 @@ export default async function leaveRequestsRoutes(
|
||||
|
||||
const totalHours = totalBusinessDays * 8;
|
||||
|
||||
for (const ac of attendanceCreates) {
|
||||
const duplicate = await prisma.attendance.findFirst({
|
||||
where: { user_id: ac.user_id, shift_date: ac.shift_date },
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Check for duplicate attendance records inside the transaction
|
||||
for (const ac of attendanceCreates) {
|
||||
const duplicate = await tx.attendance.findFirst({
|
||||
where: { user_id: ac.user_id, shift_date: ac.shift_date },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new Error(
|
||||
"Pro zvolené datumy již existují záznamy docházky",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Create attendance records for each business day
|
||||
if (attendanceCreates.length > 0) {
|
||||
await tx.attendance.createMany({ data: attendanceCreates });
|
||||
}
|
||||
|
||||
// 2. Update leave balance (vacation/sick only — not unpaid)
|
||||
if (leaveType === "vacation" || leaveType === "sick") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
|
||||
if (existingBalance) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (leaveType === "vacation") {
|
||||
updateData.vacation_used =
|
||||
Number(existingBalance.vacation_used) + totalHours;
|
||||
} else {
|
||||
updateData.sick_used =
|
||||
Number(existingBalance.sick_used) + totalHours;
|
||||
}
|
||||
await tx.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: existing.user_id,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? totalHours : 0,
|
||||
sick_used: leaveType === "sick" ? totalHours : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update request status
|
||||
await tx.leave_requests.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "approved" as leave_requests_status,
|
||||
reviewer_id: authData.userId,
|
||||
reviewed_at: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
if (duplicate) {
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.message === "Pro zvolené datumy již existují záznamy docházky"
|
||||
) {
|
||||
return error(
|
||||
reply,
|
||||
"Pro zvolené datumy již existují záznamy docházky",
|
||||
400,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Create attendance records for each business day
|
||||
if (attendanceCreates.length > 0) {
|
||||
await tx.attendance.createMany({ data: attendanceCreates });
|
||||
}
|
||||
|
||||
// 2. Update leave balance (vacation/sick only — not unpaid)
|
||||
if (leaveType === "vacation" || leaveType === "sick") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
|
||||
if (existingBalance) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (leaveType === "vacation") {
|
||||
updateData.vacation_used =
|
||||
Number(existingBalance.vacation_used) + totalHours;
|
||||
} else {
|
||||
updateData.sick_used =
|
||||
Number(existingBalance.sick_used) + totalHours;
|
||||
}
|
||||
await tx.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: existing.user_id,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? totalHours : 0,
|
||||
sick_used: leaveType === "sick" ? totalHours : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update request status
|
||||
await tx.leave_requests.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "approved" as leave_requests_status,
|
||||
reviewer_id: authData.userId,
|
||||
reviewed_at: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
|
||||
@@ -38,12 +38,7 @@ export default async function profileRoutes(
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.email) {
|
||||
const newEmail = String(body.email).trim();
|
||||
const existing = await prisma.users.findFirst({
|
||||
where: { email: newEmail, id: { not: userId } },
|
||||
});
|
||||
if (existing) return error(reply, "E-mail již existuje", 409);
|
||||
data.email = newEmail;
|
||||
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);
|
||||
@@ -65,7 +60,23 @@ export default async function profileRoutes(
|
||||
data.password_changed_at = new Date();
|
||||
}
|
||||
|
||||
await prisma.users.update({ where: { id: userId }, data });
|
||||
// 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,
|
||||
|
||||
@@ -73,10 +73,11 @@ export default async function projectFilesRoutes(
|
||||
if (!result) return error(reply, "Soubor nebyl nalezen", 404);
|
||||
|
||||
const stream = fs.createReadStream(result.filePath);
|
||||
const encodedName = encodeURIComponent(result.fileName);
|
||||
return reply
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(result.fileName)}"`,
|
||||
`attachment; filename*=UTF-8''${encodedName}`,
|
||||
)
|
||||
.header("Content-Type", result.mime)
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
@@ -179,7 +180,8 @@ export default async function projectFilesRoutes(
|
||||
if (!file) return error(reply, "Nebyl nahrán žádný soubor");
|
||||
|
||||
const subPath = parsedQuery.data.path || "";
|
||||
const fileName = file.filename;
|
||||
const rawFileName = file.filename;
|
||||
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
|
||||
|
||||
const err = await fm.uploadFile(
|
||||
project.project_number,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../../schemas/received-invoices.schema";
|
||||
import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
||||
import { toCzk } from "../../services/exchange-rates";
|
||||
import path from "path";
|
||||
|
||||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
||||
|
||||
@@ -126,9 +127,17 @@ export default async function receivedInvoicesRoutes(
|
||||
return Math.round(total * 100) / 100;
|
||||
};
|
||||
|
||||
// Also get all-time unpaid
|
||||
// Also get all-time unpaid — use DB-level aggregation for count/sums
|
||||
const stats = await prisma.received_invoices.aggregate({
|
||||
where: { status: { not: "paid" }, is_deleted: false },
|
||||
_sum: { amount: true, amount_czk: true },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// We still need per-currency breakdown for unpaid, so fetch only those
|
||||
const allUnpaid = await prisma.received_invoices.findMany({
|
||||
where: { status: { not: "paid" } },
|
||||
where: { status: { not: "paid" }, is_deleted: false },
|
||||
select: { amount: true, currency: true },
|
||||
});
|
||||
|
||||
return success(reply, {
|
||||
@@ -137,8 +146,10 @@ export default async function receivedInvoicesRoutes(
|
||||
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
|
||||
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
|
||||
unpaid: aggregateByCurrency(allUnpaid, "amount"),
|
||||
unpaid_czk: await sumCzk(allUnpaid, "amount"),
|
||||
unpaid_count: allUnpaid.length,
|
||||
unpaid_czk: stats._sum.amount_czk
|
||||
? Math.round(Number(stats._sum.amount_czk) * 100) / 100
|
||||
: await sumCzk(allUnpaid, "amount"),
|
||||
unpaid_count: stats._count,
|
||||
month_count: monthInvoices.length,
|
||||
});
|
||||
},
|
||||
@@ -188,12 +199,10 @@ export default async function receivedInvoicesRoutes(
|
||||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||||
|
||||
const mime = invoice.file_mime || "application/pdf";
|
||||
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
|
||||
return reply
|
||||
.type(mime)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${invoice.file_name}"`,
|
||||
)
|
||||
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
|
||||
.send(nasFile.data);
|
||||
},
|
||||
);
|
||||
@@ -315,7 +324,9 @@ export default async function receivedInvoicesRoutes(
|
||||
status: "unpaid",
|
||||
notes: meta.notes ? String(meta.notes) : null,
|
||||
uploaded_by: request.authData?.userId,
|
||||
file_name: file.name,
|
||||
file_name: nasResult.filePath
|
||||
? path.basename(nasResult.filePath)
|
||||
: file.name,
|
||||
file_mime: file.mime,
|
||||
file_size: file.size,
|
||||
},
|
||||
@@ -364,7 +375,7 @@ export default async function receivedInvoicesRoutes(
|
||||
vat_rate: vatRate,
|
||||
vat_amount:
|
||||
vatRate > 0
|
||||
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
|
||||
? Math.round(((amount * vatRate) / 100) * 100) / 100
|
||||
: 0,
|
||||
issue_date: body.issue_date
|
||||
? new Date(String(body.issue_date))
|
||||
@@ -544,6 +555,9 @@ export default async function receivedInvoicesRoutes(
|
||||
});
|
||||
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
|
||||
|
||||
// Delete DB record first, then NAS file — avoids orphaned file if DB delete fails
|
||||
await prisma.received_invoices.delete({ where: { id } });
|
||||
|
||||
if (existing.file_name) {
|
||||
const relPath = nasFinancialsManager.buildReceivedPath(
|
||||
existing.file_name,
|
||||
@@ -552,8 +566,6 @@ export default async function receivedInvoicesRoutes(
|
||||
);
|
||||
nasFinancialsManager.deleteReceivedInvoice(relPath);
|
||||
}
|
||||
|
||||
await prisma.received_invoices.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
|
||||
@@ -62,12 +62,16 @@ export default async function rolesRoutes(
|
||||
});
|
||||
|
||||
if (Array.isArray(body.permission_ids)) {
|
||||
await prisma.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: role.id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
});
|
||||
await prisma.$transaction(
|
||||
(body.permission_ids as number[]).map((pid) =>
|
||||
prisma.role_permissions.create({
|
||||
data: {
|
||||
role_id: role.id,
|
||||
permission_id: pid,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
|
||||
@@ -188,17 +188,19 @@ export default async function scopeTemplatesRoutes(
|
||||
});
|
||||
|
||||
if (Array.isArray(body.sections)) {
|
||||
await prisma.scope_template_sections.deleteMany({
|
||||
where: { scope_template_id: id },
|
||||
});
|
||||
await prisma.scope_template_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
scope_template_id: id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.scope_template_sections.deleteMany({
|
||||
where: { scope_template_id: id },
|
||||
});
|
||||
await tx.scope_template_sections.createMany({
|
||||
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
||||
scope_template_id: id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,17 @@ import { requireAuth, requirePermission } from "../../middleware/auth";
|
||||
import { success, error } from "../../utils/response";
|
||||
import { encrypt } from "../../utils/encryption";
|
||||
import { getSystemSettings } from "../../services/system-settings";
|
||||
import { config } from "../../config/env";
|
||||
import { OTPAuth } from "../../utils/totp";
|
||||
import * as OTPAuthLib from "otpauth";
|
||||
import { logAudit } from "../../services/audit";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import { TotpBackupSchema } from "../../schemas/auth.schema";
|
||||
import {
|
||||
TotpBackupSchema,
|
||||
TotpEnableSchema,
|
||||
TotpDisableSchema,
|
||||
TotpRequiredSchema,
|
||||
} from "../../schemas/auth.schema";
|
||||
|
||||
export default async function totpRoutes(
|
||||
fastify: FastifyInstance,
|
||||
@@ -47,12 +53,9 @@ export default async function totpRoutes(
|
||||
"/enable",
|
||||
{ preHandler: requireAuth, bodyLimit: 10240 },
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const { secret, code } = body;
|
||||
|
||||
if (!secret || !code) {
|
||||
return error(reply, "Secret a kód jsou povinné", 400);
|
||||
}
|
||||
const parsed = parseBody(TotpEnableSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { secret, code, password, current_code } = parsed.data;
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
@@ -60,41 +63,35 @@ export default async function totpRoutes(
|
||||
if (!user) return error(reply, "Uživatel nenalezen", 404);
|
||||
|
||||
if (user.totp_enabled) {
|
||||
if (!body.current_code) {
|
||||
if (!current_code) {
|
||||
return error(
|
||||
reply,
|
||||
"Aktuální TOTP kód je povinný pro změnu 2FA",
|
||||
400,
|
||||
);
|
||||
}
|
||||
const verifyResult = OTPAuth.verify(
|
||||
user.totp_secret!,
|
||||
String(body.current_code),
|
||||
);
|
||||
const verifyResult = OTPAuth.verify(user.totp_secret!, current_code);
|
||||
if (!verifyResult.valid) {
|
||||
return error(reply, "Neplatný aktuální TOTP kód", 400);
|
||||
}
|
||||
} else {
|
||||
if (!body.password) {
|
||||
if (!password) {
|
||||
return error(reply, "Heslo je povinné pro aktivaci 2FA", 400);
|
||||
}
|
||||
const valid = await bcrypt.compare(
|
||||
String(body.password),
|
||||
user.password_hash,
|
||||
);
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return error(reply, "Nesprávné heslo", 400);
|
||||
}
|
||||
}
|
||||
|
||||
const totp = new OTPAuthLib.TOTP({
|
||||
secret: OTPAuthLib.Secret.fromBase32(String(secret)),
|
||||
secret: OTPAuthLib.Secret.fromBase32(secret),
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: String(code), window: 1 });
|
||||
const delta = totp.validate({ token: code, window: 1 });
|
||||
if (delta === null) {
|
||||
return error(reply, "Neplatný TOTP kód", 400);
|
||||
}
|
||||
@@ -103,9 +100,11 @@ export default async function totpRoutes(
|
||||
const backupCodesPlain: string[] = [];
|
||||
const backupCodesHashed: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
|
||||
backupCodesPlain.push(code);
|
||||
backupCodesHashed.push(bcrypt.hashSync(code, 10));
|
||||
const plainCode = crypto.randomBytes(4).toString("hex").toUpperCase();
|
||||
backupCodesPlain.push(plainCode);
|
||||
backupCodesHashed.push(
|
||||
await bcrypt.hash(plainCode, config.security.bcryptCost),
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedSecret = encrypt(String(secret));
|
||||
@@ -140,11 +139,9 @@ export default async function totpRoutes(
|
||||
"/disable",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
if (!body.code) {
|
||||
return error(reply, "TOTP kód je povinný pro deaktivaci", 400);
|
||||
}
|
||||
const parsed = parseBody(TotpDisableSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const { code } = parsed.data;
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: request.authData!.userId },
|
||||
@@ -153,7 +150,7 @@ export default async function totpRoutes(
|
||||
return error(reply, "2FA není aktivní", 400);
|
||||
}
|
||||
|
||||
const verifyResult = OTPAuth.verify(user.totp_secret, String(body.code));
|
||||
const verifyResult = OTPAuth.verify(user.totp_secret, code);
|
||||
if (!verifyResult.valid) {
|
||||
return error(reply, "Neplatný TOTP kód", 400);
|
||||
}
|
||||
@@ -214,10 +211,9 @@ export default async function totpRoutes(
|
||||
bodyLimit: 10240,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
const required =
|
||||
body.required === true || body.required === 1 || body.required === "1";
|
||||
const parsed = parseBody(TotpRequiredSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const required = parsed.data.required;
|
||||
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { require_2fa: true },
|
||||
@@ -339,7 +335,9 @@ export default async function totpRoutes(
|
||||
return { error: "Neplatný záložní kód", status: 401 };
|
||||
}
|
||||
|
||||
await tx.totp_login_tokens.delete({ where: { id: storedToken.id } });
|
||||
await tx.totp_login_tokens.delete({
|
||||
where: { id: Number(storedToken.id) },
|
||||
});
|
||||
|
||||
backupCodes.splice(matchIndex, 1);
|
||||
await tx.users.update({
|
||||
@@ -408,6 +406,14 @@ export default async function totpRoutes(
|
||||
maxAge: config.jwt.refreshTokenSessionExpiry,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
action: "login_backup",
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
description: `Backup code login for user ${user.username}`,
|
||||
});
|
||||
|
||||
return success(reply, { access_token: accessToken, user: authData });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -38,9 +38,15 @@ export default async function tripsRoutes(
|
||||
mo = NaN;
|
||||
}
|
||||
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
|
||||
// Use explicit date strings to avoid toJSON timezone shift
|
||||
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||
const nextMonth =
|
||||
mo === 12
|
||||
? `${yr + 1}-01-01`
|
||||
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||
where.trip_date = {
|
||||
gte: new Date(yr, mo - 1, 1),
|
||||
lt: new Date(yr, mo, 1),
|
||||
gte: new Date(monthStart),
|
||||
lt: new Date(nextMonth),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -75,15 +81,8 @@ export default async function tripsRoutes(
|
||||
where: {
|
||||
is_active: true,
|
||||
roles: {
|
||||
is: {
|
||||
OR: [
|
||||
{ name: "admin" },
|
||||
{
|
||||
role_permissions: {
|
||||
some: { permissions: { name: "trips.record" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
role_permissions: {
|
||||
some: { permissions: { name: "trips.record" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -120,9 +119,17 @@ export default async function tripsRoutes(
|
||||
if (filterUserId) where.user_id = filterUserId;
|
||||
if (filterVehicleId) where.vehicle_id = filterVehicleId;
|
||||
if (query.month && query.year) {
|
||||
// Use explicit date strings to avoid toJSON timezone shift
|
||||
const yr = Number(query.year);
|
||||
const mo = Number(query.month);
|
||||
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
|
||||
const nextMonth =
|
||||
mo === 12
|
||||
? `${yr + 1}-01-01`
|
||||
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
|
||||
where.trip_date = {
|
||||
gte: new Date(Number(query.year), Number(query.month) - 1, 1),
|
||||
lt: new Date(Number(query.year), Number(query.month), 1),
|
||||
gte: new Date(monthStart),
|
||||
lt: new Date(nextMonth),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,18 @@ export default async function vehiclesRoutes(
|
||||
const existing = await prisma.vehicles.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Vozidlo nenalezeno", 404);
|
||||
|
||||
// Check for linked trips before deleting
|
||||
const tripCount = await prisma.trips.count({
|
||||
where: { vehicle_id: id },
|
||||
});
|
||||
if (tripCount > 0) {
|
||||
return error(
|
||||
reply,
|
||||
"Vozidlo má přiřazené jízdy a nelze jej smazat",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.vehicles.delete({ where: { id } });
|
||||
await logAudit({
|
||||
request,
|
||||
|
||||
Reference in New Issue
Block a user