Compare commits

..

2 Commits

Author SHA1 Message Date
BOHA
1db5060c42 v1.6.4: fix RichEditor auto-scroll when opening offers with multiple sections
Replace Quill editor.format() with direct DOM style manipulation to
avoid getSelection(true) calls that steal focus and scroll the page
to the sections area on mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:07:18 +02:00
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
8 changed files with 70 additions and 65 deletions

View File

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

View File

@@ -99,11 +99,8 @@ export default function RichEditor({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!quillRef.current) return; if (!quillRef.current) return;
const editor = quillRef.current.getEditor(); const editor = quillRef.current.getEditor();
editor.format("font", "tahoma"); editor.root.style.fontFamily = "Tahoma, sans-serif";
editor.format("size", "14px"); editor.root.style.fontSize = "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

@@ -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,