Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions frontend/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions frontend/web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

108 changes: 93 additions & 15 deletions frontend/web/src/components/ui/edi-editor-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);

Expand Down Expand Up @@ -60,13 +77,42 @@ 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();
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Cleanup handled via Monaco's own disposal lifecycle
editor.onDidDispose(() => {
domNode.removeEventListener('dragover', onDragOver);
domNode.removeEventListener('dragleave', onDragLeave);
domNode.removeEventListener('drop', onDrop);
});
};

return (
<div
className={`flex-1 p-0 bg-white min-h-[250px] flex flex-col relative group ${className}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Error banner */}
{error && (
<div className="absolute top-2 right-2 left-2 z-30 p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-md shadow-sm flex items-center justify-between">
<span>{error}</span>
Expand All @@ -75,43 +121,59 @@ export function EdiEditorPane({ value, onChange, language = 'edi', placeholder,
</button>
</div>
)}

{/* Certificate detected — bottom status bar (when value present) */}
{!error && value && showCertDetected && value.trim().startsWith('-----BEGIN') && value.trim().includes('-----END') && (
<div className="absolute bottom-0 left-0 right-0 z-30 px-4 py-2 bg-green-50 border-t border-green-200 text-green-700 text-xs font-semibold flex items-center gap-2 pointer-events-none">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Certificate detected — ready to save
</div>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{/* Drag-over full-screen overlay */}
{isDragging && (
<div className="absolute inset-0 bg-indigo-50/90 z-20 flex flex-col items-center justify-center border-2 border-indigo-400 border-dashed m-2 rounded-lg backdrop-blur-sm transition-all duration-200">
<UploadCloud className="w-10 h-10 text-indigo-500 mb-3 animate-bounce" />
<span className="text-indigo-700 font-semibold text-lg tracking-tight">Drop file to load</span>
</div>
)}
{!value && !isDragging && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-6 text-center pointer-events-none">
{cornerPlaceholder && (
<div className="absolute top-4 left-6 text-xs text-slate-400 font-mono text-left whitespace-pre-wrap pointer-events-none opacity-90">
{cornerPlaceholder}
</div>
)}
<UploadCloud className="w-12 h-12 text-slate-200 mb-4" />
<h3 className="text-lg font-medium text-slate-900 mb-1">Drag and drop your file here</h3>
<p className="text-sm text-slate-500 mb-6">{placeholder || `or upload a file, or click anywhere to paste raw ${language.toUpperCase()}`}</p>
<label className="pointer-events-auto cursor-pointer inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 transition-colors">
Upload File
<input type="file" className="hidden" accept={acceptedFileExtensions} onChange={handleFileUpload} />
</label>

{/* Corner placeholder (e.g. PEM hint) */}
{!value && !isDragging && cornerPlaceholder && (
<div className="absolute top-4 left-6 z-10 text-xs text-slate-400 font-mono text-left whitespace-pre-wrap pointer-events-none opacity-90">
{cornerPlaceholder}
</div>
)}

{/* Optional "Drag and drop" heading for the EDI editor */}
{showEmptyState && !value && !isDragging && (
<div className="absolute inset-x-0 top-1/3 z-10 flex flex-col items-center gap-1 pointer-events-none text-center px-6">
<UploadCloud className="w-10 h-10 text-slate-150 mb-1" />
<p className="text-sm font-medium text-slate-400">
{placeholder || `Drag & drop or paste ${language.toUpperCase()}`}
</p>
</div>
)}

{/* Monaco editor — always rendered */}
<Editor
height="100%"
language={language}
value={value}
onChange={(val) => onChange(val || '')}
theme="soopa-theme"
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
options={{
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
lineNumbersMinChars: 3,
wordWrap: 'on',
folding: true,
fontSize,
padding: { top: 16, bottom: 16 },
renderLineHighlight: 'none',
hideCursorInOverviewRuler: true,
Expand All @@ -122,6 +184,22 @@ export function EdiEditorPane({ value, onChange, language = 'edi', placeholder,
},
}}
/>

{/* Bottom toolbar — always shown when editor is empty, sits below Monaco */}
{!value && !isDragging && (
<div className="absolute bottom-0 left-0 right-0 z-10 flex items-center justify-center gap-3 px-4 py-2.5 bg-white/90 backdrop-blur-sm border-t border-slate-100">
<span className="text-xs text-slate-400 flex items-center gap-1">
<UploadCloud className="w-3 h-3" />
Drag &amp; drop or
</span>
<label className="cursor-pointer inline-flex items-center justify-center px-3 py-1.5 border border-transparent text-xs font-semibold rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 transition-colors">
<UploadCloud className="w-3.5 h-3.5 mr-1.5" />
Upload File
<input type="file" className="hidden" accept={acceptedFileExtensions} onChange={handleFileUpload} />
</label>
{extraActions}
</div>
)}
</div>
);
}
27 changes: 27 additions & 0 deletions frontend/web/src/components/ui/switch.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName

export { Switch }
85 changes: 20 additions & 65 deletions frontend/web/src/features/partners/components/As2PartnerDetails.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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<HTMLInputElement>(null);


const { register, handleSubmit, reset, control, formState: { isDirty } } = useForm({
defaultValues: {
Expand Down Expand Up @@ -59,42 +59,6 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner,
rotateCertificates.mutate({ id: partner.id, payload: { action: 'generate' } });
};

const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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: '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;
Expand Down Expand Up @@ -208,28 +172,10 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner,
<div className="flex justify-between items-center pb-2">
<h4 className="text-sm font-semibold text-slate-900 uppercase tracking-wider">Certificates</h4>
<div className="flex items-center gap-3">
{partner.is_local && (
<Button variant="outline" size="sm" onClick={handleGenerateCertificate} disabled={rotateCertificates.isPending}>
{rotateCertificates.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Generate Certificate
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setPasteDialogOpen(true)} disabled={rotateCertificates.isPending}>
<ClipboardPaste className="w-4 h-4 mr-2" />
Paste
New Certificate
</Button>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={rotateCertificates.isPending}>
<Upload className="w-4 h-4 mr-2" />
Upload
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
multiple
accept=".pem,.crt,.cer,.key"
className="hidden"
/>
</div>
</div>

Expand Down Expand Up @@ -277,16 +223,25 @@ export function As2PartnerDetails({ partner, onCancel }: { partner: AS2Partner,
setPasteDialogOpen(open);
if (!open) setPasteValue('');
}}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-[780px]">
<DialogHeader>
<DialogTitle>Paste Certificate</DialogTitle>
<DialogTitle>Update Certificate</DialogTitle>
</DialogHeader>
<div className="py-4">
<Textarea
className="min-h-[200px] font-mono text-xs p-4 bg-slate-50 border-slate-200"
placeholder={"-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----"}
<CertificateInput
value={pasteValue}
onChange={(e) => setPasteValue(e.target.value)}
onChange={setPasteValue}
extraActions={partner.is_local && !pasteValue ? (
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-slate-200 text-sm font-medium rounded-md shadow-sm text-slate-700 bg-white hover:bg-slate-50 transition-colors"
onClick={handleGenerateCertificate}
disabled={rotateCertificates.isPending}
>
{rotateCertificates.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Generate Certificate
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) : undefined}
/>
</div>
<DialogFooter>
Expand Down
Loading
Loading