Compare commits

...

2 Commits

Author SHA1 Message Date
BOHA
4cabba395d v1.6.3: fix project file upload, filter responsible user by permission
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:26:32 +02:00
BOHA
59b478f262 v1.6.2: fix RichEditor auto-scroll and PDF offers multi-page header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:23:36 +02:00
9 changed files with 79 additions and 87 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.6.1", "version": "1.6.3",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useCallback, useEffect } from "react"; import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import ReactQuill from "react-quill-new"; import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
@@ -96,11 +96,14 @@ export default function RichEditor({
[onChange], [onChange],
); );
useEffect(() => { useLayoutEffect(() => {
if (!quillRef.current) return; if (!quillRef.current) return;
const editor = quillRef.current.getEditor(); const editor = quillRef.current.getEditor();
editor.format("font", "tahoma"); editor.format("font", "tahoma");
editor.format("size", "14px"); editor.format("size", "14px");
// Quill auto-focuses on mount with existing content, which scrolls
// the page to the editor. Blur to prevent unwanted scroll.
editor.blur();
}, []); }, []);
return ( return (

View File

@@ -1,12 +1,14 @@
import { queryOptions } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter"; import { jsonQuery } from "../apiAdapter";
export const userListOptions = () => export const userListOptions = (permission?: string) =>
queryOptions({ queryOptions({
queryKey: ["users"], queryKey: ["users", { permission }],
queryFn: () => { queryFn: () => {
// The users endpoint returns { success, data: { items, ... } } or { success, data: [...] } const url = permission
return jsonQuery<Record<string, unknown>>("/api/admin/users"); ? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
: "/api/admin/users?limit=100";
return jsonQuery<Record<string, unknown>>(url);
}, },
}); });

View File

@@ -105,7 +105,7 @@ export default function ProjectDetail() {
const isPending = projectQuery.isPending; const isPending = projectQuery.isPending;
const notes: Note[] = project?.project_notes || []; const notes: Note[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions()); const { data: usersData } = useQuery(userListOptions("projects.view"));
const rawUsers = ((usersData as Record<string, unknown>)?.items ?? const rawUsers = ((usersData as Record<string, unknown>)?.items ??
usersData ?? usersData ??
[]) as any[]; []) as any[];

View File

@@ -632,14 +632,6 @@ ${indentCSS}
border: none; border: none;
vertical-align: top; vertical-align: top;
} }
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- Page break helpers ---- */ /* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; } table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; } table.items tbody tr { break-inside: avoid; }
@@ -696,30 +688,16 @@ ${indentCSS}
display: block; display: block;
width: 100%; width: 100%;
} }
.first-content {
margin-top: 0 !important;
}
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
} }
</style> </style>
</head> </head>
<body> <body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ --> <!-- ============ QUOTATION (full header in thead repeats on every page) ============ -->
<div class="quotation-page"> <div class="quotation-page">
<table class="page-layout"> <table class="page-layout">
<thead> <thead>
<tr><td> <tr><td>
<div class="logo-header">${logoImg}</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<div class="page-header"> <div class="page-header">
<div class="left"> <div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div> <div class="page-title">${escapeHtml(t("title"))}</div>
@@ -727,9 +705,13 @@ ${indentCSS}
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""} ${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div> <div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div> </div>
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
</div> </div>
<hr class="separator" /> <hr class="separator" />
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="addresses"> <div class="addresses">
<div class="address-block left"> <div class="address-block left">
<div class="address-label">${escapeHtml(t("customer"))}</div> <div class="address-label">${escapeHtml(t("customer"))}</div>
@@ -763,7 +745,6 @@ ${indentCSS}
${totalsHtml} ${totalsHtml}
</div> </div>
</div> </div>
</div>
</td></tr> </td></tr>
</tbody> </tbody>
</table> </table>

View File

@@ -156,7 +156,6 @@ export default async function projectFilesRoutes(
"/upload", "/upload",
{ {
preHandler: requirePermission("projects.files"), preHandler: requirePermission("projects.files"),
bodyLimit: config.nas.maxUploadSize,
}, },
async (request, reply) => { async (request, reply) => {
const parsedQuery = parseProjectFilesQuery(request.query); const parsedQuery = parseProjectFilesQuery(request.query);
@@ -183,13 +182,25 @@ export default async function projectFilesRoutes(
const rawFileName = file.filename; const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_"); const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const buffer = await file.toBuffer();
const err = await fm.uploadFile( const err = await fm.uploadFile(
project.project_number, project.project_number,
subPath, subPath,
file.file, buffer,
fileName, fileName,
); );
if (err !== null) return error(reply, err); if (err !== null) {
request.log.error(
{
err,
project_number: project.project_number,
subPath,
fileName,
},
"uploadFile failed",
);
return error(reply, err);
}
await logAudit({ await logAudit({
request, request,

View File

@@ -22,8 +22,12 @@ export default async function usersRoutes(
"/", "/",
{ preHandler: requirePermission("users.view") }, { preHandler: requirePermission("users.view") },
async (request, reply) => { async (request, reply) => {
const params = parsePagination(request.query as Record<string, unknown>); const query = request.query as Record<string, unknown>;
const result = await listUsers(params); const params = parsePagination(query);
const permission = query.permission
? String(query.permission)
: undefined;
const result = await listUsers({ ...params, permission });
return paginated( return paginated(
reply, reply,

View File

@@ -1,6 +1,5 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { pipeline } from "stream/promises";
import { config } from "../config/env"; import { config } from "../config/env";
import { localDateStr, localTimeStr } from "../utils/date"; import { localDateStr, localTimeStr } from "../utils/date";
@@ -237,7 +236,7 @@ export class NasFileManager {
if (isLink) { if (isLink) {
try { try {
const target = fs.readlinkSync(fullPath); const target = fs.readlinkSync(fullPath);
item.link_target = target.replace(/\//g, "\\"); item.link_target = target;
} catch { } catch {
// ignore // ignore
} }
@@ -288,14 +287,14 @@ export class NasFileManager {
path: subPath, path: subPath,
items, items,
breadcrumb, breadcrumb,
full_path: realDirPath.replace(/\//g, "\\"), full_path: realDirPath,
}; };
} }
public async uploadFile( public async uploadFile(
projectNumber: string, projectNumber: string,
subPath: string, subPath: string,
fileStream: NodeJS.ReadableStream, fileBuffer: Buffer,
fileName: string, fileName: string,
): Promise<string | null> { ): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath); const dirPath = this.resolveProjectPath(projectNumber, subPath);
@@ -304,13 +303,20 @@ export class NasFileManager {
} }
try { try {
const stat = await fs.promises.stat(dirPath); await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o775 });
if (!stat.isDirectory()) { } catch {
return "Cílová složka neexistuje"; return "Cílová složka neexistuje";
} }
let stat: fs.Stats;
try {
stat = await fs.promises.stat(dirPath);
} catch { } catch {
return "Cílová složka neexistuje"; return "Cílová složka neexistuje";
} }
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
const originalName = path.basename(fileName); const originalName = path.basename(fileName);
let safeName = this.sanitizeFilename(originalName); let safeName = this.sanitizeFilename(originalName);
@@ -323,46 +329,27 @@ export class NasFileManager {
return "Tento typ souboru není povolen"; return "Tento typ souboru není povolen";
} }
const tempPath = path.join( // MIME validation
require("os").tmpdir(), const typeResult = await FileType.fromBuffer(fileBuffer);
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
await pipeline(fileStream, fs.createWriteStream(tempPath));
} catch {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
try {
const typeResult = await FileType.fromFile(tempPath);
if (typeResult) { if (typeResult) {
if (this.isSuspiciousMime(typeResult.mime)) { if (this.isSuspiciousMime(typeResult.mime)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě"; return "Obsah souboru neodpovídá jeho příponě";
} }
const expectedMime = ext ? MIME_MAP[ext] : null; const expectedMime = ext ? MIME_MAP[ext] : null;
if (expectedMime && typeResult.mime !== expectedMime) { if (expectedMime && typeResult.mime !== expectedMime) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě"; return "Obsah souboru neodpovídá jeho příponě";
} }
} }
} catch {
// If file-type fails, continue without MIME check
}
let destPath = dirPath + "/" + safeName; let destPath = dirPath + "/" + safeName;
// Attempt atomic rename; if destination exists, append counter // If destination exists, append counter
let renamed = false;
let attempts = 0; let attempts = 0;
const maxAttempts = 1000; const maxAttempts = 1000;
do { while (attempts < maxAttempts) {
try { try {
await fs.promises.rename(tempPath, destPath); await fs.promises.writeFile(destPath, fileBuffer, { flag: "wx" });
renamed = true; return null;
break;
} catch (err) { } catch (err) {
const e = err as NodeJS.ErrnoException; const e = err as NodeJS.ErrnoException;
if (e.code === "EEXIST") { if (e.code === "EEXIST") {
@@ -371,19 +358,14 @@ export class NasFileManager {
safeName = base + "_" + attempts + (ext ? "." + ext : ""); safeName = base + "_" + attempts + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName; destPath = dirPath + "/" + safeName;
} else { } else {
break; return `Nepodařilo se uložit soubor (${e?.message || String(e)})`;
}
} }
} }
} while (!renamed && attempts < maxAttempts);
if (!renamed) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor"; return "Nepodařilo se uložit soubor";
} }
return null;
}
public downloadFile( public downloadFile(
projectNumber: string, projectNumber: string,
filePath: string, filePath: string,

View File

@@ -33,6 +33,7 @@ export interface ListUsersParams {
sort: string; sort: string;
order: "asc" | "desc"; order: "asc" | "desc";
search: string; search: string;
permission?: string;
} }
export interface CreateUserData { export interface CreateUserData {
@@ -56,10 +57,10 @@ export interface UpdateUserData {
} }
export async function listUsers(params: ListUsersParams) { export async function listUsers(params: ListUsersParams) {
const { page, limit, skip, sort, order, search } = params; const { page, limit, skip, sort, order, search, permission } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where = search let where: any = search
? { ? {
OR: [ OR: [
{ username: { contains: search } }, { username: { contains: search } },
@@ -70,6 +71,14 @@ export async function listUsers(params: ListUsersParams) {
} }
: {}; : {};
if (permission) {
where.roles = {
role_permissions: {
some: { permissions: { name: permission } },
},
};
}
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([
prisma.users.findMany({ prisma.users.findMany({
where, where,