diff --git a/frontend/web/package.json b/frontend/web/package.json index d4dcf541..7fc080d2 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-radio-group": "^1.4.2", "@radix-ui/react-select": "^2.3.1", "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-switch": "^1.3.3", "@radix-ui/react-tabs": "^1.1.17", "@radix-ui/react-toast": "^1.2.18", "@tanstack/react-query": "^5.101.1", diff --git a/frontend/web/pnpm-lock.yaml b/frontend/web/pnpm-lock.yaml index d636f327..113de175 100644 --- a/frontend/web/pnpm-lock.yaml +++ b/frontend/web/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.3.0 version: 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-switch': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-tabs': specifier: ^1.1.17 version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -819,6 +822,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.3.3': + resolution: {integrity: sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tabs@1.1.17': resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==} peerDependencies: @@ -2814,6 +2830,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-switch@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.5 diff --git a/frontend/web/src/components/ui/edi-editor-pane.tsx b/frontend/web/src/components/ui/edi-editor-pane.tsx index 27e2ecf9..87030923 100644 --- a/frontend/web/src/components/ui/edi-editor-pane.tsx +++ b/frontend/web/src/components/ui/edi-editor-pane.tsx @@ -11,9 +11,26 @@ export interface EdiEditorPaneProps { cornerPlaceholder?: string; className?: string; acceptedFileExtensions?: string; + extraActions?: React.ReactNode; + fontSize?: number; + showCertDetected?: boolean; + /** When false, suppresses the big "Drag and drop" heading. Buttons are always shown. */ + showEmptyState?: boolean; } -export function EdiEditorPane({ value, onChange, language = 'edi', placeholder, cornerPlaceholder, className = '', acceptedFileExtensions = ".edi,.json,.txt,.x12" }: EdiEditorPaneProps) { +export function EdiEditorPane({ + value, + onChange, + language = 'edi', + placeholder, + cornerPlaceholder, + className = '', + acceptedFileExtensions = '.edi,.json,.txt,.x12', + extraActions, + fontSize = 13, + showCertDetected = false, + showEmptyState = true, +}: EdiEditorPaneProps) { const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); @@ -60,6 +77,35 @@ export function EdiEditorPane({ value, onChange, language = 'edi', placeholder, registerEdiLanguageAndTheme(monaco); }; + // Monaco's content area intercepts drag events, so we attach listeners + // directly to its DOM node to ensure drag-and-drop always works. + const handleEditorMount = (editor: any) => { + const domNode = editor.getDomNode() as HTMLElement | null; + if (!domNode) return; + + const onDragOver = (e: DragEvent) => { e.preventDefault(); setIsDragging(true); }; + const onDragLeave = (e: DragEvent) => { e.preventDefault(); setIsDragging(false); }; + const onDrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + await applyFile(e.dataTransfer.files[0]); + } + }; + + domNode.addEventListener('dragover', onDragOver); + domNode.addEventListener('dragleave', onDragLeave); + domNode.addEventListener('drop', onDrop); + + // Cleanup handled via Monaco's own disposal lifecycle + editor.onDidDispose(() => { + domNode.removeEventListener('dragover', onDragOver); + domNode.removeEventListener('dragleave', onDragLeave); + domNode.removeEventListener('drop', onDrop); + }); + }; + return (
+ {/* Error banner */} {error && (
{error} @@ -75,29 +122,43 @@ export function EdiEditorPane({ value, onChange, language = 'edi', placeholder,
)} + + {/* Certificate detected — bottom status bar (when value present) */} + {!error && value && showCertDetected && value.includes('-----BEGIN CERTIFICATE-----') && value.includes('-----END CERTIFICATE-----') && ( +
+ + + + Certificate detected — ready to save +
+ )} + + {/* Drag-over full-screen overlay */} {isDragging && ( -
+
Drop file to load
)} - {!value && !isDragging && ( -
- {cornerPlaceholder && ( -
- {cornerPlaceholder} -
- )} - -

Drag and drop your file here

-

{placeholder || `or upload a file, or click anywhere to paste raw ${language.toUpperCase()}`}

- + + {/* Corner placeholder (e.g. PEM hint) */} + {!value && !isDragging && cornerPlaceholder && ( +
+ {cornerPlaceholder} +
+ )} + + {/* Optional "Drag and drop" heading for the EDI editor */} + {showEmptyState && !value && !isDragging && ( +
+ +

+ {placeholder || `Drag & drop or paste ${language.toUpperCase()}`} +

)} + {/* Monaco editor — always rendered */} onChange(val || '')} theme="soopa-theme" beforeMount={handleEditorWillMount} + onMount={handleEditorMount} options={{ automaticLayout: true, minimap: { enabled: false }, @@ -112,6 +174,7 @@ export function EdiEditorPane({ value, onChange, language = 'edi', placeholder, lineNumbersMinChars: 3, wordWrap: 'on', folding: true, + fontSize, padding: { top: 16, bottom: 16 }, renderLineHighlight: 'none', hideCursorInOverviewRuler: true, @@ -122,6 +185,22 @@ export function EdiEditorPane({ value, onChange, language = 'edi', placeholder, }, }} /> + + {/* Bottom toolbar — always shown when editor is empty, sits below Monaco */} + {!value && !isDragging && ( +
+ + + Drag & drop or + + + {extraActions} +
+ )}
); } diff --git a/frontend/web/src/components/ui/switch.tsx b/frontend/web/src/components/ui/switch.tsx new file mode 100644 index 00000000..455c23b6 --- /dev/null +++ b/frontend/web/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/frontend/web/src/features/partners/components/As2PartnerDetails.tsx b/frontend/web/src/features/partners/components/As2PartnerDetails.tsx index bee275e2..55f70dd8 100644 --- a/frontend/web/src/features/partners/components/As2PartnerDetails.tsx +++ b/frontend/web/src/features/partners/components/As2PartnerDetails.tsx @@ -1,16 +1,16 @@ -import { useState, useRef } from 'react'; +import { useState } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; -import { Textarea } from '@/components/ui/textarea'; import type { AS2Partner } from '../types'; import { useCertificatesExportQuery, useUpdatePlatformPartnerMutation, useRotateCertificatesMutation } from '../api/partnerHooks'; -import { Copy, Download, Loader2, ChevronDown, ChevronRight, CheckCircle2, Clock, Upload, RefreshCw, ClipboardPaste } from 'lucide-react'; +import { Copy, Download, Loader2, ChevronDown, ChevronRight, CheckCircle2, Clock, ClipboardPaste } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useToast } from '@/hooks/use-toast'; import { usePlatformConfig } from '@/features/platform/api/configHooks'; import { Combobox } from '@/components/ui/combobox'; +import { CertificateInput } from './CertificateInput'; export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner, onCancel?: () => void }) { const { toast } = useToast(); @@ -24,7 +24,7 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner, const [pasteValue, setPasteValue] = useState(''); const { data: certs, isLoading: certsLoading, error: certsError } = useCertificatesExportQuery(partner.id); - const fileInputRef = useRef(null); + const { register, handleSubmit, reset, control, formState: { isDirty } } = useForm({ defaultValues: { @@ -56,46 +56,18 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner, }; const handleGenerateCertificate = () => { - rotateCertificates.mutate({ id: partner.id, payload: { action: 'generate' } }); - }; - - const handleFileUpload = async (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - - try { - let publicCert = ''; - let privateKey = ''; - - for (let i = 0; i < files.length; i++) { - const text = await files[i].text(); - if (text.includes('-----BEGIN CERTIFICATE-----')) { - const match = text.match(/-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----/g); - if (match && match.length > 0) publicCert += match.join('\n') + '\n'; - } - if (text.includes('-----BEGIN PRIVATE KEY-----') || text.includes('-----BEGIN RSA PRIVATE KEY-----')) { - const match = text.match(/-----BEGIN (?:RSA )?PRIVATE KEY-----[^-]+-----END (?:RSA )?PRIVATE KEY-----/g); - if (match && match.length > 0) privateKey += match.join('\n') + '\n'; + rotateCertificates.mutate( + { id: partner.id, payload: { action: 'generate' } }, + { + onSuccess: () => { + setPasteDialogOpen(false); + setPasteValue(''); } } - - rotateCertificates.mutate({ - id: partner.id, - payload: { - action: 'upload', - public_cert_pem: publicCert.trim() || undefined as any, - private_key_pem: (partner.is_local && privateKey.trim()) ? privateKey.trim() : undefined - } - }); - } catch { // eslint-disable-line no-unused-vars - toast({ title: 'Error', description: 'Failed to read uploaded files.', variant: 'destructive' }); - } finally { - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } + ); }; + const handlePasteSubmit = () => { if (!pasteValue.trim()) return; @@ -208,28 +180,10 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner,

Certificates

- {partner.is_local && ( - - )} - -
@@ -277,16 +231,25 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner, setPasteDialogOpen(open); if (!open) setPasteValue(''); }}> - + - Paste Certificate + Update Certificate
-