* fix: replace 6-word limit with 150-character limit for project names Replaces the word-based validation with character-based validation as originally requested in #971. The 6-word limit was causing issues with small words and separators being counted equally, and didn't match the original requirement for a character limit. Changes: - Backend: Replace wordCount validator with len validator (1-150 chars) - Frontend: Replace word count validation with character length check - UI already has line-clamp-3 for display truncation Fixes #998 * fix: make password_digest migration compatible with all schema versions Fixes a critical bug where the make-password-optional migration would silently fail when upgrading from v1.0.0 or running on fresh v1.1.0-dev installations. The migration was trying to SELECT columns (ai_provider, openai_api_key, ollama_base_url, ollama_model) that don't exist in the users table at that point in the migration chain, causing the INSERT...SELECT to fail and leaving password_digest as NOT NULL. This prevented OIDC auto-provisioning from creating new users without passwords. The fix dynamically detects which columns exist in the users table using PRAGMA table_info and only selects columns that are guaranteed to exist. Missing columns (AI-related fields) will receive their default values from the new table schema. Changes: - Added dynamic column detection using PRAGMA table_info - Only SELECT columns that exist in the current users table - AI columns get default values if they don't exist yet - Applied same fix to both up and down migrations - Properly handle password/password_digest column name migration Fixes #1075 * feat: add configurable file upload limit via environment variable Add FILE_UPLOAD_LIMIT_MB environment variable to make file upload limits configurable. Previously hardcoded at 10MB, users can now customize this via Docker environment variables or .env configuration to support larger file attachments. Changes: - Add FILE_UPLOAD_LIMIT_MB config with 10MB default fallback - Update multer limits in tasks/attachments and projects routes - Update Express body parser limits to use dynamic config - Add /api/config endpoint to expose file limit to frontend - Update frontend validation to fetch and use server config - Add configService.ts for caching server configuration - Update documentation with new environment variable Fixes #1000
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CloudArrowUpIcon, PaperClipIcon } from '@heroicons/react/24/outline';
|
|
import { Attachment } from '../../../entities/Attachment';
|
|
import {
|
|
uploadAttachment,
|
|
deleteAttachment,
|
|
downloadAttachment,
|
|
fetchAttachments,
|
|
validateFile,
|
|
getAttachmentType,
|
|
} from '../../../utils/attachmentsService';
|
|
import { useToast } from '../../Shared/ToastContext';
|
|
import ConfirmDialog from '../../Shared/ConfirmDialog';
|
|
import AttachmentCard from '../../Shared/AttachmentCard';
|
|
import AttachmentPreview from '../../Shared/AttachmentPreview';
|
|
|
|
interface TaskAttachmentsCardProps {
|
|
taskUid: string;
|
|
onAttachmentsCountChange?: (count: number) => void;
|
|
}
|
|
|
|
const TaskAttachmentsCard: React.FC<TaskAttachmentsCardProps> = ({
|
|
taskUid,
|
|
onAttachmentsCountChange,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const { showSuccessToast, showErrorToast } = useToast();
|
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [previewAttachment, setPreviewAttachment] =
|
|
useState<Attachment | null>(null);
|
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
|
const [attachmentToDelete, setAttachmentToDelete] =
|
|
useState<Attachment | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Load attachments on mount
|
|
useEffect(() => {
|
|
loadAttachments();
|
|
}, [taskUid]);
|
|
|
|
// Handle Escape key to close preview modal
|
|
useEffect(() => {
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && previewAttachment) {
|
|
setPreviewAttachment(null);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleEscape);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, [previewAttachment]);
|
|
|
|
const loadAttachments = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await fetchAttachments(taskUid);
|
|
setAttachments(data);
|
|
onAttachmentsCountChange?.(data.length);
|
|
} catch (error) {
|
|
console.error('Error loading attachments:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Validate file
|
|
const validation = await validateFile(file);
|
|
if (!validation.valid) {
|
|
showErrorToast(validation.error || 'Invalid file');
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check attachment limit
|
|
if (attachments.length >= 20) {
|
|
showErrorToast(
|
|
t(
|
|
'task.attachments.limitReached',
|
|
'Maximum 20 attachments allowed per task'
|
|
)
|
|
);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Upload file
|
|
setUploading(true);
|
|
try {
|
|
const newAttachment = await uploadAttachment(taskUid, file);
|
|
const updatedAttachments = [...attachments, newAttachment];
|
|
setAttachments(updatedAttachments);
|
|
onAttachmentsCountChange?.(updatedAttachments.length);
|
|
showSuccessToast(
|
|
t(
|
|
'task.attachments.uploadSuccess',
|
|
'File uploaded successfully'
|
|
)
|
|
);
|
|
} catch (error: any) {
|
|
showErrorToast(
|
|
error.message ||
|
|
t('task.attachments.uploadError', 'Failed to upload file')
|
|
);
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDelete = (attachment: Attachment) => {
|
|
setAttachmentToDelete(attachment);
|
|
setIsConfirmDialogOpen(true);
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (!attachmentToDelete) return;
|
|
|
|
try {
|
|
await deleteAttachment(taskUid, attachmentToDelete.uid);
|
|
const updatedAttachments = attachments.filter(
|
|
(a) => a.uid !== attachmentToDelete.uid
|
|
);
|
|
setAttachments(updatedAttachments);
|
|
onAttachmentsCountChange?.(updatedAttachments.length);
|
|
showSuccessToast(
|
|
t(
|
|
'task.attachments.deleteSuccess',
|
|
'Attachment deleted successfully'
|
|
)
|
|
);
|
|
if (previewAttachment?.uid === attachmentToDelete.uid) {
|
|
setPreviewAttachment(null);
|
|
}
|
|
} catch (error: any) {
|
|
showErrorToast(
|
|
error.message ||
|
|
t(
|
|
'task.attachments.deleteError',
|
|
'Failed to delete attachment'
|
|
)
|
|
);
|
|
} finally {
|
|
setIsConfirmDialogOpen(false);
|
|
setAttachmentToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleDownload = (attachment: Attachment) => {
|
|
downloadAttachment(attachment.uid);
|
|
};
|
|
|
|
const handlePreview = (attachment: Attachment) => {
|
|
setPreviewAttachment(
|
|
previewAttachment?.uid === attachment.uid ? null : attachment
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
|
|
<PaperClipIcon className="h-4 w-4 mr-2" />
|
|
{t('task.attachments.title', 'Attachments')}
|
|
</h4>
|
|
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t('common.loading', 'Loading...')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
|
|
<PaperClipIcon className="h-4 w-4 mr-2" />
|
|
{t('task.attachments.title', 'Attachments')} (
|
|
{attachments.length})
|
|
</h4>
|
|
|
|
{/* Grid Layout for Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{/* Upload Card */}
|
|
<div
|
|
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col cursor-pointer hover:shadow-lg transition-shadow"
|
|
style={{ minHeight: '250px', maxHeight: '250px' }}
|
|
onClick={() => !uploading && fileInputRef.current?.click()}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
onChange={handleFileSelect}
|
|
disabled={uploading}
|
|
accept=".pdf,.doc,.docx,.txt,.md,.png,.jpg,.jpeg,.gif,.svg,.webp,.xls,.xlsx,.csv,.zip"
|
|
/>
|
|
<div
|
|
className="bg-gray-200 dark:bg-gray-700 flex flex-col items-center justify-center rounded-t-lg border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 transition-colors"
|
|
style={{ height: '140px' }}
|
|
>
|
|
<CloudArrowUpIcon className="h-12 w-12 text-gray-400 dark:text-gray-500 mb-2" />
|
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{uploading
|
|
? t(
|
|
'task.attachments.uploading',
|
|
'Uploading...'
|
|
)
|
|
: t(
|
|
'task.attachments.clickToUpload',
|
|
'Click to upload'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="p-4 flex-1 flex flex-col justify-center">
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
|
{t('task.attachments.maxSize', 'Max 10MB')}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
|
{t(
|
|
'task.attachments.supportedFormats',
|
|
'PDF, images, docs & more'
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Attachment Cards */}
|
|
{attachments.map((attachment) => (
|
|
<AttachmentCard
|
|
key={attachment.uid}
|
|
attachment={attachment}
|
|
taskUid={taskUid}
|
|
onDelete={handleDelete}
|
|
onDownload={handleDownload}
|
|
onPreview={handlePreview}
|
|
isPreviewOpen={
|
|
previewAttachment?.uid === attachment.uid
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Full Preview Modal */}
|
|
{previewAttachment && (
|
|
<div
|
|
className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
|
onClick={() => setPreviewAttachment(null)}
|
|
>
|
|
<div
|
|
className={`bg-white dark:bg-gray-800 rounded-lg ${
|
|
getAttachmentType(previewAttachment.mime_type) ===
|
|
'pdf'
|
|
? 'w-full max-w-[95vw] h-[95vh]'
|
|
: 'max-w-4xl max-h-[90vh]'
|
|
} overflow-auto`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{previewAttachment.original_filename}
|
|
</h3>
|
|
<button
|
|
onClick={() => setPreviewAttachment(null)}
|
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="p-1">
|
|
<AttachmentPreview
|
|
attachment={previewAttachment}
|
|
maxHeight={
|
|
getAttachmentType(
|
|
previewAttachment.mime_type
|
|
) === 'pdf'
|
|
? 'calc(95vh - 80px)'
|
|
: '70vh'
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirm Delete Dialog */}
|
|
{isConfirmDialogOpen && attachmentToDelete && (
|
|
<ConfirmDialog
|
|
title={t(
|
|
'task.attachments.deleteConfirmTitle',
|
|
'Delete Attachment'
|
|
)}
|
|
message={t(
|
|
'task.attachments.deleteConfirmMessage',
|
|
'Are you sure you want to delete this attachment? This action cannot be undone.'
|
|
)}
|
|
onConfirm={handleDeleteConfirm}
|
|
onCancel={() => {
|
|
setIsConfirmDialogOpen(false);
|
|
setAttachmentToDelete(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TaskAttachmentsCard;
|