Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,9 @@ def apply_security_headers(response):
csp = (
"default-src 'self'; "
"img-src 'self' data: https:; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net https://uicdn.toast.com; "
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; "
"script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh; "
"script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://uicdn.toast.com; "
"connect-src 'self' ws: wss:; "
"frame-ancestors 'none'"
)
Expand Down
32 changes: 31 additions & 1 deletion app/routes/clients.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
from flask_babel import gettext as _
from flask_login import login_required, current_user
import app as app_module
Expand Down Expand Up @@ -42,7 +42,22 @@ def list_clients():
@login_required
def create_client():
"""Create a new client"""
# Detect AJAX/JSON request
try:
wants_json = (
request.headers.get('X-Requested-With') == 'XMLHttpRequest'
or request.is_json
or request.accept_mimetypes['application/json'] >= request.accept_mimetypes['text/html']
)
except Exception:
wants_json = False

if not current_user.is_admin:
if wants_json:
return jsonify({
'error': 'forbidden',
'message': _('Only administrators can create clients')
}), 403
flash(_('Only administrators can create clients'), 'error')
return redirect(url_for('clients.list_clients'))

Expand All @@ -66,6 +81,8 @@ def create_client():

# Validate required fields
if not name:
if wants_json:
return jsonify({'error': 'validation_error', 'messages': ['Client name is required']}), 400
flash('Client name is required', 'error')
try:
current_app.logger.warning("Validation failed: missing client name")
Expand All @@ -75,6 +92,8 @@ def create_client():

# Check if client name already exists
if Client.query.filter_by(name=name).first():
if wants_json:
return jsonify({'error': 'validation_error', 'messages': ['A client with this name already exists']}), 400
flash('A client with this name already exists', 'error')
try:
current_app.logger.warning("Validation failed: duplicate client name '%s'", name)
Expand All @@ -86,6 +105,8 @@ def create_client():
try:
default_hourly_rate = Decimal(default_hourly_rate) if default_hourly_rate else None
except ValueError:
if wants_json:
return jsonify({'error': 'validation_error', 'messages': ['Invalid hourly rate format']}), 400
flash('Invalid hourly rate format', 'error')
try:
current_app.logger.warning("Validation failed: invalid hourly rate '%s'", default_hourly_rate)
Expand All @@ -106,13 +127,22 @@ def create_client():

db.session.add(client)
if not safe_commit('create_client', {'name': name}):
if wants_json:
return jsonify({'error': 'db_error', 'message': 'Could not create client due to a database error.'}), 500
flash('Could not create client due to a database error. Please check server logs.', 'error')
return render_template('clients/create.html')

# Log client creation
app_module.log_event("client.created", user_id=current_user.id, client_id=client.id)
app_module.track_event(current_user.id, "client.created", {"client_id": client.id})

if wants_json:
return jsonify({
'id': client.id,
'name': client.name,
'default_hourly_rate': float(client.default_hourly_rate) if client.default_hourly_rate is not None else None
}), 201

flash(f'Client "{name}" created successfully', 'success')
return redirect(url_for('clients.view_client', client_id=client.id))

Expand Down
23 changes: 23 additions & 0 deletions app/routes/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ def edit_task(task_id):
# Preload context for potential validation errors
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
users = User.query.order_by(User.username).all()
project_id = request.form.get('project_id', type=int)
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
priority = request.form.get('priority', 'medium')
Expand All @@ -225,6 +226,15 @@ def edit_task(task_id):
if not name:
flash('Task name is required', 'error')
return render_template('tasks/edit.html', task=task, projects=projects, users=users)

# Validate project selection
if not project_id:
flash('Project is required', 'error')
return render_template('tasks/edit.html', task=task, projects=projects, users=users)
new_project = Project.query.filter_by(id=project_id, status='active').first()
if not new_project:
flash('Selected project does not exist or is inactive', 'error')
return render_template('tasks/edit.html', task=task, projects=projects, users=users)

# Parse estimated hours
try:
Expand All @@ -243,6 +253,19 @@ def edit_task(task_id):
return render_template('tasks/edit.html', task=task, projects=projects, users=users)

# Update task
# Handle project change first so any early returns (status flows) still persist it
if project_id != task.project_id:
old_project_id = task.project_id
task.project_id = project_id
# Keep related time entries consistent with the task's project
try:
for entry in task.time_entries.all():
entry.project_id = project_id
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='project_change', details=f"Project changed from {old_project_id} to {project_id}"))
except Exception:
# If anything goes wrong here, fall back to just changing the task
pass

task.name = name
task.description = description
task.priority = priority
Expand Down
84 changes: 84 additions & 0 deletions app/static/enhanced-ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -639,3 +639,87 @@
top: 0;
}

/* ============================================
Markdown content styling (fallback to match app)
Applies when Tailwind Typography is not available
============================================ */
.prose, .prose-sm {
color: #2D3748;
line-height: 1.7;
}
.dark .prose, .dark .prose-sm { color: #E2E8F0; }
.prose h1, .prose h2, .prose h3, .prose-sm h1, .prose-sm h2, .prose-sm h3 {
color: inherit;
font-weight: 700;
margin: 0.75rem 0 0.5rem;
}
.prose h4, .prose h5, .prose h6, .prose-sm h4, .prose-sm h5, .prose-sm h6 {
color: inherit;
font-weight: 600;
margin: 0.75rem 0 0.5rem;
}
.prose p, .prose-sm p { margin: 0.5rem 0; }
.prose a, .prose-sm a { color: #3B82F6; text-decoration: underline; }
.dark .prose a, .dark .prose-sm a { color: #60A5FA; }
.prose ul, .prose ol, .prose-sm ul, .prose-sm ol { padding-left: 1.25rem; margin: 0.5rem 0; }
.prose li, .prose-sm li { margin: 0.25rem 0; }
.prose code, .prose-sm code {
background: #F7F9FB;
color: #1F2937;
padding: 0.1rem 0.3rem;
border-radius: 4px;
}
.dark .prose code, .dark .prose-sm code { background: #1F2937; color: #E5E7EB; }
.prose pre, .prose-sm pre {
background: #0B1220;
color: #E5E7EB;
padding: 0.75rem 1rem;
border-radius: 8px;
overflow-x: auto;
}
.dark .prose pre, .dark .prose-sm pre { background: #0B1220; }
.prose blockquote, .prose-sm blockquote {
border-left: 4px solid #3B82F6;
padding-left: 0.75rem;
margin: 0.75rem 0;
color: #475569;
}
.dark .prose blockquote, .dark .prose-sm blockquote { color: #94A3B8; }
.prose table, .prose-sm table { width: 100%; border-collapse: collapse; }
.prose table th, .prose table td, .prose-sm table th, .prose-sm table td {
border: 1px solid #E2E8F0; padding: 0.5rem 0.75rem;
}
.dark .prose table th, .dark .prose table td, .dark .prose-sm table th, .dark .prose-sm table td { border-color: #4A5568; }
.prose img, .prose-sm img { max-width: 100%; border-radius: 8px; }

/* ============================================
Toast UI Editor – theme bridging to match app
============================================ */
.toastui-editor-defaultUI {
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 8px;
}
.dark .toastui-editor-defaultUI {
background: #2D3748;
border-color: #4A5568;
}
.toastui-editor-defaultUI .toastui-editor-toolbar {
background: transparent;
border-bottom-color: #E2E8F0;
}
.dark .toastui-editor-defaultUI .toastui-editor-toolbar { border-bottom-color: #4A5568; }
.toastui-editor-defaultUI .ProseMirror,
.toastui-editor-contents {
color: #2D3748;
}
.dark .toastui-editor-defaultUI .ProseMirror,
.dark .toastui-editor-contents {
color: #E2E8F0;
}
.toastui-editor-contents a { color: #3B82F6; }
.dark .toastui-editor-contents a { color: #60A5FA; }
.toastui-editor-contents pre { background: #0B1220; color: #E5E7EB; border-radius: 8px; }
.toastui-editor-contents code { background: #F7F9FB; color: #1F2937; padding: 0.1rem 0.3rem; border-radius: 4px; }
.dark .toastui-editor-contents code { background: #1F2937; color: #E5E7EB; }

22 changes: 21 additions & 1 deletion app/templates/clients/view.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "components/ui.html" import confirm_dialog %}

{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
Expand All @@ -7,7 +8,16 @@ <h1 class="text-2xl font-bold">{{ client.name }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">Client details and associated projects.</p>
</div>
{% if current_user.is_admin %}
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Client</a>
<div class="flex gap-2">
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Client</a>
<button type="button" class="bg-red-600 text-white px-4 py-2 rounded-lg mt-4 md:mt-0"
onclick="document.getElementById('confirmDeleteClient-{{ client.id }}').classList.remove('hidden')">
{{ _('Delete Client') }}
</button>
<form id="confirmDeleteClient-{{ client.id }}-form" method="POST" action="{{ url_for('clients.delete_client', client_id=client.id) }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
</div>
{% endif %}
</div>

Expand Down Expand Up @@ -70,4 +80,14 @@ <h2 class="text-lg font-semibold mb-4">Projects</h2>
</div>
</div>
</div>
{% if current_user.is_admin %}
{{ confirm_dialog(
'confirmDeleteClient-' ~ client.id,
'Delete Client',
'Are you sure you want to delete this client? This action cannot be undone.',
'Delete',
'Cancel',
'danger'
) }}
{% endif %}
{% endblock %}
2 changes: 1 addition & 1 deletion app/templates/projects/_kanban_tailwind.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ <h3 id="col-{{ col.key }}-title" class="text-base font-semibold flex items-cente
{% endif %}
</div>
</div>
<div class="kanban-column-body p-3 space-y-3 overflow-y-auto h-96 flex-grow" data-status="{{ col.key }}" role="list" aria-label="{{ _('Column') }}: {{ col.label }}" tabindex="0">
<div class="kanban-column-body p-3 space-y-3 overflow-y-auto max-h-[75vh] flex-grow" data-status="{{ col.key }}" role="list" aria-label="{{ _('Column') }}: {{ col.label }}" tabindex="0">
{% set column_tasks = tasks|selectattr('status', 'equalto', col.key)|list %}
{% if column_tasks %}
{% for task in column_tasks %}
Expand Down
24 changes: 22 additions & 2 deletions app/templates/projects/view.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "components/ui.html" import confirm_dialog %}

{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
Expand All @@ -7,7 +8,16 @@ <h1 class="text-2xl font-bold">{{ project.name }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.client.name }}</p>
</div>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Project</a>
<div class="flex gap-2">
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Edit Project</a>
<button type="button" class="bg-red-600 text-white px-4 py-2 rounded-lg mt-4 md:mt-0"
onclick="document.getElementById('confirmDeleteProject-{{ project.id }}').classList.remove('hidden')">
{{ _('Delete Project') }}
</button>
<form id="confirmDeleteProject-{{ project.id }}-form" method="POST" action="{{ url_for('projects.delete_project', project_id=project.id) }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
</div>
{% endif %}
</div>

Expand All @@ -19,7 +29,7 @@ <h2 class="text-lg font-semibold mb-4">Details</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Description</h3>
<p>{{ project.description or 'No description provided.' }}</p>
<div class="prose prose-sm dark:prose-invert max-w-none">{{ (project.description or 'No description provided.') | markdown | safe }}</div>
</div>
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Status</h3>
Expand Down Expand Up @@ -110,6 +120,16 @@ <h3 class="text-lg font-semibold mb-4">{{ _('Tasks for this project') }}</h3>
</div>
</div>
</div>
{% if current_user.is_admin %}
{{ confirm_dialog(
'confirmDeleteProject-' ~ project.id,
'Delete Project',
'Are you sure you want to delete this project? This action cannot be undone.',
'Delete',
'Cancel',
'danger'
) }}
{% endif %}
{% endblock %}

{% block scripts_extra %}{% endblock %}
12 changes: 6 additions & 6 deletions app/templates/tasks/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ <h1 class="text-2xl font-bold">{{ _('Edit Task') }}</h1>
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Task') }}</a>
</div>

<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="xl:col-span-2">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<!-- Header -->
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg mb-4">
Expand All @@ -33,8 +33,8 @@ <h2 class="text-xl font-semibold">{{ _('Edit Task') }}</h2>
</div>

<!-- Edit Task Form -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="xl:col-span-2">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow">
<div class="border-b border-border-light dark:border-border-dark p-4">
<h6 class="font-semibold flex items-center gap-2"><i class="fas fa-edit text-yellow-600"></i>{{ _('Task Information') }}</h6>
Expand All @@ -58,7 +58,7 @@ <h6 class="font-semibold flex items-center gap-2"><i class="fas fa-edit text-yel
<span><i class="fas fa-align-left mr-2 text-primary"></i>{{ _('Description') }}</span>
</label>
<div class="markdown-editor-wrapper">
<textarea class="form-input d-none" id="description" name="description" rows="12" placeholder="{{ _('Provide detailed information about the task, requirements, and any specific instructions...') }}">{{ task.description or '' }}</textarea>
<textarea class="form-input hidden" id="description" name="description" rows="12" placeholder="{{ _('Provide detailed information about the task, requirements, and any specific instructions...') }}">{{ task.description or '' }}</textarea>
<div id="description_editor"></div>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Add context, requirements, or specific instructions for the task') }}</p>
Expand Down Expand Up @@ -149,7 +149,7 @@ <h6 class="font-semibold flex items-center gap-2"><i class="fas fa-edit text-yel
</div>

<!-- Sidebar -->
<div class="lg:col-span-1 space-y-4">
<div class="xl:col-span-1 space-y-4">
<!-- Progress -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow">
<div class="border-b border-border-light dark:border-border-dark p-4">
Expand Down
Loading
Loading