fix: code review — security, dead code, performance, consistency

Critical fixes:
- XSS: escapeHtml() on all user-supplied text in email notifications
- Budget PATCH: added mutex lock + availability validation (prevents corruption)
- batchResolveNames: fixed wrong signature for budget request earmark names

Dead code cleanup:
- Deleted 8 unused PostComposition* files (replaced by PostDetail full page)

Performance:
- budget-helpers: single-fetch with computeFromEntries(), optional prefetch param
- post-composition: parallelized text + thumbnail fetches with Promise.all

Consistency:
- PostDetail.jsx: native <select> → PortalSelect (matches all panels)
- Finance.jsx: 11 hardcoded English table headers → t() with i18n keys
- PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys
- App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback)
- UploadZone: proper useRef pattern, no vanilla JS document.createElement
- All file inputs: className="hidden" → absolute w-0 h-0 opacity-0
- ArtefactDetailPanel: removed campaign/project selects (inherited from post)
- TranslationDetailPanel: removed brand/linked post selects (inherited from post)
- ApproverMultiSelect: portal-based dropdown (fixes clipping in modals)
- Thumbnail fix: post-composition constructs URL from filename (was undefined)
- Upload fix: UploadZone with drag-and-drop for design + video artefacts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-16 14:17:08 +03:00
parent ce4d6025d7
commit 49e1a796ed
34 changed files with 622 additions and 1172 deletions
+26 -41
View File
@@ -1,11 +1,13 @@
import { useState, useEffect, useContext } from 'react'
import { Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
import { Copy, Eye, Lock, Send, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
import UploadZone from './UploadZone'
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import TabbedModal from './TabbedModal'
import Modal from './Modal'
import { useToast } from './ToastContainer'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import PortalSelect from './PortalSelect'
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
const { brands } = useContext(AppContext)
@@ -284,67 +286,53 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select
<PortalSelect
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
onChange={val => handleAssignmentChange(val)}
options={[{ value: '', label: t('issues.unassigned') }, ...teamMembers.map(member => ({ value: member.id || member._id, label: member.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.unassigned')}</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
/>
</div>
{/* Team */}
{teams.length > 0 && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<select
<PortalSelect
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
onChange={async (val) => {
const resolvedVal = val || null
setTeamId(resolvedVal || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await api.patch(`/issues/${issueId}`, { team_id: resolvedVal })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
options={[{ value: '', label: t('issues.allTeams') }, ...teams.map(team => ({ value: team.id || team._id, label: team.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
))}
</select>
/>
</div>
)}
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select
<PortalSelect
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
onChange={async (val) => {
const resolvedVal = val || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
await api.patch(`/issues/${issueId}`, { brand_id: resolvedVal });
loadIssueDetails();
onUpdate();
} catch {}
}}
options={[{ value: '', label: t('issues.noBrand') }, ...(brands || []).map(b => ({ value: b._id || b.Id, label: b.name }))]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('issues.noBrand')}</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
/>
</div>
{/* Internal Notes */}
@@ -501,15 +489,12 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{activeTab === 'attachments' && (
<div className="p-6 space-y-5">
{/* Upload */}
<label className="block">
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
<p className="text-sm text-text-secondary">
{uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')}
</p>
</div>
</label>
<UploadZone
onUpload={handleFileUpload}
uploading={uploadingFile}
label={t('issues.clickToUpload')}
compact
/>
{/* Attachments List */}
<div className="space-y-2">