diff --git a/Dockerfile b/Dockerfile
index aac0e05..f6ad05e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -65,14 +65,17 @@ COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
-# Copy project files
-COPY . .
+# Create non-root user early (before copying files)
+RUN useradd -m -u 1000 timetracker
+
+# Copy project files with correct ownership
+COPY --chown=timetracker:timetracker . .
# Also install certificate generation script to a stable path used by docs/compose
-COPY scripts/generate-certs.sh /scripts/generate-certs.sh
+COPY --chown=timetracker:timetracker scripts/generate-certs.sh /scripts/generate-certs.sh
# Copy compiled assets from frontend stage (overwriting the stale one from COPY .)
-COPY --from=frontend /app/app/static/dist/output.css /app/app/static/dist/output.css
+COPY --chown=timetracker:timetracker --from=frontend /app/app/static/dist/output.css /app/app/static/dist/output.css
# Create all directories and set permissions in a single layer
RUN mkdir -p \
@@ -88,7 +91,7 @@ RUN mkdir -p \
&& chmod -R 755 /app/app/static/uploads /app/static/uploads
# Copy the startup script
-COPY docker/start-fixed.py /app/start.py
+COPY --chown=timetracker:timetracker docker/start-fixed.py /app/start.py
# Fix line endings and set permissions in a single layer
RUN find /app/docker -name "*.sh" -o -name "*.py" | xargs dos2unix 2>/dev/null || true \
@@ -113,10 +116,9 @@ RUN find /app/docker -name "*.sh" -o -name "*.py" | xargs dos2unix 2>/dev/null |
/app/docker/simple_test.sh \
/scripts/generate-certs.sh
-# Create non-root user and set ownership
-RUN useradd -m -u 1000 timetracker \
- && chown -R timetracker:timetracker \
- /app \
+# Set ownership only for directories that need write access
+# (Most files already have correct ownership from COPY --chown)
+RUN chown -R timetracker:timetracker \
/data \
/app/logs \
/app/instance \
diff --git a/app/templates/admin/quote_pdf_layout.html b/app/templates/admin/quote_pdf_layout.html
index abfacb4..2fb3560 100644
--- a/app/templates/admin/quote_pdf_layout.html
+++ b/app/templates/admin/quote_pdf_layout.html
@@ -1863,6 +1863,33 @@ function initializePDFEditor() {
}
});
+ // For Image nodes, convert scale changes to width/height changes
+ // This ensures the size is stored correctly when resizing
+ if (node.className === 'Image') {
+ transformer.on('transformend', function() {
+ const image = node;
+ const scaleX = image.scaleX();
+ const scaleY = image.scaleY();
+
+ // Update width and height based on scale
+ const newWidth = image.width() * scaleX;
+ const newHeight = image.height() * scaleY;
+
+ // Reset scale to 1 and apply the new dimensions
+ image.width(newWidth);
+ image.height(newHeight);
+ image.scaleX(1);
+ image.scaleY(1);
+
+ // Update properties panel if visible
+ if (selectedElement === image) {
+ updatePropertiesPanel(image);
+ }
+
+ layer.draw();
+ });
+ }
+
layer.add(transformer);
layer.draw();
@@ -2538,7 +2565,7 @@ function initializePDFEditor() {
const w = Math.round(attrs.width || 100);
const h = Math.round(attrs.height || 50);
{% raw %}
- bodyContent += ` \n`;
+ bodyContent += `
\n`;
{% endraw %}
} else if (child.className === 'Rect') {
const w = Math.round(attrs.width || 100);
@@ -3092,11 +3119,96 @@ table tr:last-child td {
// Redraw grid for new size
drawGrid();
+ // Fix logo images: Konva Image nodes need to reload the image from URL
+ // When Konva Image nodes are serialized/deserialized, the image data is lost
+ // We need to reload the image from the URL and restore all attributes
+ if (LOGO_URL) {
+ const logoImages = layer.find('[name="logo"]');
+ logoImages.forEach(function(logoNode) {
+ if (logoNode.className === 'Image') {
+ // Save all attributes before reloading
+ const savedAttrs = {
+ x: logoNode.x(),
+ y: logoNode.y(),
+ width: logoNode.width(),
+ height: logoNode.height(),
+ scaleX: logoNode.scaleX(),
+ scaleY: logoNode.scaleY(),
+ rotation: logoNode.rotation(),
+ opacity: logoNode.opacity(),
+ draggable: logoNode.draggable(),
+ visible: logoNode.visible()
+ };
+
+ // Store reference to update elements array
+ const oldNode = logoNode;
+ const parent = logoNode.getParent();
+ const index = logoNode.getZIndex();
+
+ // Reload the image from URL
+ Konva.Image.fromURL(LOGO_URL, function(newImage) {
+ // Calculate actual size considering scale
+ // If scaleX/scaleY are not 1, the actual size is width*scaleX and height*scaleY
+ let finalWidth = savedAttrs.width;
+ let finalHeight = savedAttrs.height;
+
+ // If scale was applied, we need to account for it
+ // Reset scale to 1 and apply the scaled dimensions as the new width/height
+ if (savedAttrs.scaleX !== 1 || savedAttrs.scaleY !== 1) {
+ finalWidth = savedAttrs.width * savedAttrs.scaleX;
+ finalHeight = savedAttrs.height * savedAttrs.scaleY;
+ }
+
+ // Restore all attributes, but reset scale to 1 and use calculated dimensions
+ newImage.setAttrs({
+ x: savedAttrs.x,
+ y: savedAttrs.y,
+ width: finalWidth,
+ height: finalHeight,
+ scaleX: 1,
+ scaleY: 1,
+ rotation: savedAttrs.rotation,
+ opacity: savedAttrs.opacity,
+ draggable: savedAttrs.draggable,
+ visible: savedAttrs.visible
+ });
+ newImage.name('logo');
+
+ // Replace the old node with the new one
+ oldNode.destroy();
+ parent.add(newImage);
+ newImage.zIndex(index);
+
+ // Update elements array
+ const elementIndex = elements.findIndex(e => e.node === oldNode);
+ if (elementIndex !== -1) {
+ elements[elementIndex].node = newImage;
+ } else {
+ // Add to elements array if not found
+ elements.push({ type: 'logo', node: newImage });
+ }
+
+ // Setup selection for the new image
+ setupSelection(newImage);
+
+ layer.draw();
+ }, function(error) {
+ console.error('Failed to load logo image:', error);
+ // Keep the old node if image fails to load
+ });
+ }
+ });
+ }
+
// Re-setup selections for all elements (skip background and grid lines)
layer.children.forEach(child => {
if (child !== background &&
child.className !== 'Transformer' &&
!(child.className === 'Line' && child.attrs.name === 'grid-line')) {
+ // Skip logo images as they're handled above
+ if (child.className === 'Image' && child.attrs.name === 'logo') {
+ return;
+ }
setupSelection(child);
// If it's a table Group, also setup selection on children
if (child.className === 'Group' && (child.attrs.name === 'items-table' || child.attrs.name === 'expenses-table')) {
diff --git a/app/templates/base.html b/app/templates/base.html
index 0d0777d..bb6961b 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -219,7 +219,7 @@
{% set work_open = ep.startswith('projects.') or ep.startswith('tasks.') or ep.startswith('issues.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('weekly_goals.') or ep.startswith('project_templates.') or ep.startswith('gantt.') %}
{% set calendar_open = ep.startswith('calendar.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('recurring_invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('mileage.') or ep.startswith('per_diem.') or ep.startswith('budget_alerts.') or ep.startswith('invoice_approvals.') or ep.startswith('payment_gateways.') or ep.startswith('scheduled_reports.') or ep.startswith('custom_reports.') %}
- {% set crm_open = ep.startswith('clients.') or ep.startswith('quotes.') %}
+ {% set crm_open = ep.startswith('clients.') or ep.startswith('quotes.') or ep.startswith('contacts.') or ep.startswith('deals.') or ep.startswith('leads.') %}
{% set inventory_open = ep.startswith('inventory.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') or ep.startswith('integrations.') %}
@@ -244,7 +244,7 @@
- {% if (not settings or settings.ui_allow_calendar) and current_user.ui_show_calendar %}
+ {% if is_module_enabled('calendar') %}