Update CSP, dependencies, and add choice label generator

Replaces nginx.conf CSP map with inline policy and updates the policy for development. Adds new dependencies including Mantine, Radix, Tabler, FontAwesome, and others. Removes the fetch-openapi.js script and introduces generate-choice-labels.cjs to auto-generate TypeScript choice label constants from Orval enums, updating the api:gen script to run this generator. Also updates orval and other dev dependencies, and makes minor formatting changes in orval.config.ts.
This commit is contained in:
2026-01-26 00:10:47 +01:00
parent ed1b7de7a7
commit 7c768c9be3
5 changed files with 237 additions and 42 deletions

View File

@@ -14,11 +14,6 @@ http {
sendfile on;
keepalive_timeout 65;
# Content Security Policy - organized for better readability
map $request_uri $csp_policy {
default "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src * data: blob:; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data: https://fonts.gstatic.com";
}
server {
listen 80;
server_name _;
@@ -32,7 +27,7 @@ http {
location / {
try_files $uri /index.html;
# Ensure CSP is present on SPA document responses too
add_header Content-Security-Policy $csp_policy always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
}
# -------------------------
@@ -64,7 +59,7 @@ http {
client_max_body_size 50m;
# Ensure CSP is also present on proxied responses
add_header Content-Security-Policy $csp_policy always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
}
# -------------------------
@@ -74,10 +69,7 @@ http {
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CSP Policy - Centrally defined above for better maintainability
# To add new domains, update the $csp_policy map above
# Development: More permissive for external resources
# Production: Should be more restrictive and use nonces/hashes where possible
add_header Content-Security-Policy $csp_policy always;
# Minimal, valid CSP for development (apply on all responses)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
}
}

View File

@@ -8,33 +8,48 @@
"build": "tsc -b && tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"api:gen": "orval --config src/orval.config.ts"
"api:gen": "orval --config src/orval.config.ts && node scripts/generate-choice-labels.cjs"
},
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.1",
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@mantine/core": "^8.3.11",
"@mantine/dates": "^8.3.11",
"@mantine/hooks": "^8.3.11",
"@radix-ui/react-switch": "^1.2.6",
"@tabler/icons-react": "^3.36.1",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"@types/react-router": "^5.1.20",
"axios": "^1.13.0",
"dotenv": "^17.2.3",
"dayjs": "^1.11.19",
"framer-motion": "^12.25.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.70.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.1",
"react-toastify": "^11.0.5",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^24.10.4",
"@types/react": "^19.1.10",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.2.3",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"orval": "^7.13.2",
"orval": "^8.0.2",
"prettier": "^3.7.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",

View File

@@ -1,25 +0,0 @@
import fs from "fs";
import path from "path";
import axios from "axios";
// Single config point
const config = { schemaUrl: "/api/schema/", baseUrl: "/api/" };
async function main() {
const outDir = path.resolve("./src/openapi");
const outFile = path.join(outDir, "schema.json");
const base = process.env.VITE_API_BASE_URL || "http://localhost:8000";
const url = new URL(config.schemaUrl, base).toString();
console.log(`[openapi] Fetching schema from ${url}`);
const res = await axios.get(url, { headers: { Accept: "application/json" } });
await fs.promises.mkdir(outDir, { recursive: true });
await fs.promises.writeFile(outFile, JSON.stringify(res.data, null, 2), "utf8");
console.log(`[openapi] Wrote ${outFile}`);
}
main().catch((err) => {
console.error("[openapi] Failed to fetch schema:", err?.message || err);
process.exit(1);
});

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* Generates TypeScript file with choice labels from Orval-generated enum files
*
* Usage: node scripts/generate-choice-labels.js
*
* This script reads the TypeScript enum files generated by Orval and extracts
* choice labels from JSDoc comments (format: * `value` - Label) and generates
* a TypeScript file with runtime-accessible label mappings.
*/
const fs = require('fs');
const path = require('path');
// Configuration
const MODELS_DIRS = [
path.join(__dirname, '../src/api/generated/private/models'),
path.join(__dirname, '../src/api/generated/public/models')
];
const OUTPUT_PATH = path.join(__dirname, '../src/constants/choiceLabels.ts');
// Convert camelCase to UPPER_SNAKE_CASE
function camelToSnakeCase(str) {
return str
.replace(/([A-Z])/g, '_$1')
.replace(/^_/, '') // Remove leading underscore
.toUpperCase();
}
// Extract enum labels from TypeScript file comments
function parseEnumFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const filename = path.basename(filePath, '.ts');
// Extract all JSDoc comment blocks
// Match all: /** ... */
const commentRegex = /\/\*\*\s*\n([\s\S]*?)\*\//g;
const comments = [];
let match;
while ((match = commentRegex.exec(content)) !== null) {
comments.push(match[1]);
}
if (comments.length === 0) {
return null;
}
// Try to find labels in all comment blocks
// Parse labels from comment lines like: * `value` - Label
// Note: The asterisk might have 0 or more spaces after it
const labelPattern = /\*\s*`([^`]+)`\s*-\s*(.+)/g;
const labels = {};
for (const comment of comments) {
let labelMatch;
while ((labelMatch = labelPattern.exec(comment)) !== null) {
const [, value, label] = labelMatch;
labels[value] = label.trim();
}
}
// Only return if we found labels
if (Object.keys(labels).length === 0) {
return null;
}
// Extract enum name from the type definition
// Match: export type EnumName = ...
const typeMatch = content.match(/export\s+type\s+(\w+Enum)\s*=/);
const enumName = typeMatch ? typeMatch[1] : null;
if (!enumName) {
return null;
}
return { enumName, labels };
}
// Main function
function generateLabels() {
console.log('🔍 Scanning Orval-generated enum files...');
const enums = {};
let totalEnumFiles = 0;
// Check each models directory
for (const MODELS_DIR of MODELS_DIRS) {
const dirName = path.basename(path.dirname(MODELS_DIR));
if (!fs.existsSync(MODELS_DIR)) {
console.warn(`⚠️ Models directory not found: ${MODELS_DIR}`);
continue;
}
console.log(`📁 Scanning ${dirName}/models...`);
// Read all enum files
const files = fs.readdirSync(MODELS_DIR);
const enumFiles = files.filter(f => f.endsWith('Enum.ts'));
console.log(` Found ${enumFiles.length} enum files`);
totalEnumFiles += enumFiles.length;
// Parse each enum file
for (const file of enumFiles) {
const filePath = path.join(MODELS_DIR, file);
const result = parseEnumFile(filePath);
if (result) {
// Check if enum already exists from another directory
if (enums[result.enumName]) {
// Compare labels to see if they're the same
const existing = JSON.stringify(enums[result.enumName]);
const current = JSON.stringify(result.labels);
if (existing !== current) {
console.warn(` ⚠️ ${result.enumName}: Different labels found in ${dirName}, keeping first version`);
}
} else {
enums[result.enumName] = result.labels;
console.log(`${result.enumName}: ${Object.keys(result.labels).length} labels`);
}
}
}
}
if (totalEnumFiles === 0) {
console.error('❌ No enum files found in any models directory');
console.error(' Run "npm run api:gen" first to generate API client');
process.exit(1);
}
if (Object.keys(enums).length === 0) {
console.error('❌ No enum labels found in generated files');
process.exit(1);
}
// Generate TypeScript file
console.log('📝 Generating TypeScript file...');
const tsContent = `/**
* Auto-generated choice labels from Orval-generated enum files
* Generated by: scripts/generate-choice-labels.cjs
*
* DO NOT EDIT MANUALLY - Run \`npm run api:gen\` to regenerate
*/
${Object.entries(enums).map(([enumName, labels]) => {
const enumBaseName = enumName.replace(/Enum$/, '');
const constantName = camelToSnakeCase(enumBaseName) + '_LABELS';
return `/**
* Labels for ${enumName}
* ${Object.entries(labels).map(([value, label]) => `* ${value}: ${label}`).join('\n * ')}
*/
export const ${constantName} = ${JSON.stringify(labels, null, 2)} as const;
`;
}).join('\n')}
/**
* Type-safe helper to get choice label
*/
export function getChoiceLabel<T extends string>(
labels: Record<string, string>,
value: T | undefined | null
): string {
if (!value) return '';
return labels[value] || value;
}
/**
* Get options array for select dropdowns
*/
export function getChoiceOptions<T extends string>(
labels: Record<T, string>
): Array<{ value: T; label: string }> {
return Object.entries(labels).map(([value, label]) => ({
value: value as T,
label: label as string,
}));
}
// Auto-generate all OPTIONS exports
${Object.entries(enums).map(([enumName]) => {
const enumBaseName = enumName.replace(/Enum$/, '');
const labelsConstName = camelToSnakeCase(enumBaseName) + '_LABELS';
const optionsConstName = camelToSnakeCase(enumBaseName) + '_OPTIONS';
return `export const ${optionsConstName} = getChoiceOptions(${labelsConstName});`;
}).join('\n')}
`;
// Ensure output directory exists
const outputDir = path.dirname(OUTPUT_PATH);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write file
fs.writeFileSync(OUTPUT_PATH, tsContent, 'utf8');
console.log(`✅ Generated ${OUTPUT_PATH}`);
console.log(` Found ${Object.keys(enums).length} enum types with labels`);
}
// Run the script
try {
generateLabels();
} catch (error) {
console.error('❌ Error generating choice labels:', error.message);
process.exit(1);
}

View File

@@ -1,6 +1,7 @@
import { defineConfig } from "orval";
const backendUrl = process.env.VITE_BACKEND_URL || "http://localhost:8000";
const backendUrl =
process.env.VITE_BACKEND_URL || 'http://localhost:8000';
// může se hodit pokud nechceme při buildu generovat klienta (nechat false pro produkci nebo vynechat)
const SKIP_ORVAL = process.env.SKIP_ORVAL === "true";