diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx index e2cfac0a..7b4db33a 100644 --- a/decnet_web/src/components/CanaryTokens.tsx +++ b/decnet_web/src/components/CanaryTokens.tsx @@ -14,135 +14,7 @@ import { import { extractError, fmt, fmtBytes } from './CanaryTokens/helpers'; import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui'; import { CreateTokenModal } from './CanaryTokens/CreateTokenModal'; - -// ─── BLOB UPLOAD MODAL ───────────────────────────────────────────────────── - -interface UploadModalProps { - onClose: () => void; - onUploaded: (blob: BlobRow) => void; -} - -const UploadModal: React.FC = ({ onClose, onUploaded }) => { - const panelRef = useRef(null); - useEscapeKey(onClose, true); - useFocusTrap(panelRef, true); - - const [file, setFile] = useState(null); - const [uploading, setUploading] = useState(false); - const [error, setError] = useState(null); - const [dragOver, setDragOver] = useState(false); - - const handleSubmit = async () => { - if (!file) return setError('Pick a file first.'); - setUploading(true); - setError(null); - try { - const fd = new FormData(); - fd.append('file', file); - const res = await api.post('/canary/blobs', fd, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - onUploaded(res.data); - } catch (err) { - setError(extractError(err, 'Upload failed.')); - } finally { - setUploading(false); - } - }; - - return ( -
{ if (e.target === e.currentTarget) onClose(); }} - style={{ - position: 'fixed', inset: 0, - backgroundColor: 'rgba(0,0,0,0.6)', - display: 'flex', justifyContent: 'center', alignItems: 'center', - zIndex: 1000, - }} - > -
-
-
UPLOAD CANARY ARTIFACT
- -
- -
{ e.preventDefault(); setDragOver(true); }} - onDragLeave={() => setDragOver(false)} - onDrop={(e) => { - e.preventDefault(); - setDragOver(false); - const f = e.dataTransfer.files?.[0]; - if (f) setFile(f); - }} - style={{ - border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`, - padding: '32px', - textAlign: 'center', - marginBottom: '16px', - cursor: 'pointer', - background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent', - }} - onClick={() => document.getElementById('canary-blob-input')?.click()} - > - -
- {file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'} -
- {!file && ( -
- DOCX · XLSX · PDF · HTML · PNG/JPEG · plain configs -
- )} - setFile(e.target.files?.[0] || null)} - /> -
- -
- - DECNET injects the callback server-side; the original bytes stay on the master. -
- - {error && ( -
{error}
- )} - -
- - -
-
-
- ); -}; +import { UploadModal } from './CanaryTokens/UploadModal'; // ─── FILE DROP MODAL ─────────────────────────────────────────────────────── diff --git a/decnet_web/src/components/CanaryTokens/UploadModal.test.tsx b/decnet_web/src/components/CanaryTokens/UploadModal.test.tsx new file mode 100644 index 00000000..c3e1e72d --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/UploadModal.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { UploadModal } from './UploadModal'; + +vi.mock('../../hooks/useFocusTrap', () => ({ useFocusTrap: () => {} })); + +describe('UploadModal', () => { + it('renders the title and the empty drop zone hint', () => { + render( {}} onUploaded={() => {}} />); + expect(screen.getByText('UPLOAD CANARY ARTIFACT')).toBeInTheDocument(); + expect(screen.getByText('Drop a file here or click to browse')).toBeInTheDocument(); + }); + + it('renders the operator-warning banner about server-side injection', () => { + render( {}} onUploaded={() => {}} />); + expect( + screen.getByText(/DECNET injects the callback server-side/), + ).toBeInTheDocument(); + }); + + it('UPLOAD button stays disabled until a file is picked', () => { + render( {}} onUploaded={() => {}} />); + const upload = screen.getByText('UPLOAD') as HTMLButtonElement; + expect(upload.disabled).toBe(true); + }); + + it('CANCEL invokes onClose', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByText('CANCEL')); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/decnet_web/src/components/CanaryTokens/UploadModal.tsx b/decnet_web/src/components/CanaryTokens/UploadModal.tsx new file mode 100644 index 00000000..238019aa --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/UploadModal.tsx @@ -0,0 +1,139 @@ +import React, { useRef, useState } from 'react'; +import { Upload, X, AlertTriangle } from '../../icons'; +import api from '../../utils/api'; +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; +import type { BlobRow } from './types'; +import { extractError, fmtBytes } from './helpers'; +import { BTN_PRIMARY, BTN_GHOST } from './ui'; + +interface Props { + onClose: () => void; + onUploaded: (blob: BlobRow) => void; +} + +/** Drop-or-browse upload of an operator-supplied artifact. The + * server keeps the original bytes on the master and injects the + * callback marker downstream — the warning banner repeats that to + * the operator before they hand over a sensitive document. */ +export const UploadModal: React.FC = ({ onClose, onUploaded }) => { + const panelRef = useRef(null); + useEscapeKey(onClose, true); + useFocusTrap(panelRef, true); + + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [dragOver, setDragOver] = useState(false); + + const handleSubmit = async () => { + if (!file) return setError('Pick a file first.'); + setUploading(true); + setError(null); + try { + const fd = new FormData(); + fd.append('file', file); + const res = await api.post('/canary/blobs', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + onUploaded(res.data); + } catch (err) { + setError(extractError(err, 'Upload failed.')); + } finally { + setUploading(false); + } + }; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: 'fixed', inset: 0, + backgroundColor: 'rgba(0,0,0,0.6)', + display: 'flex', justifyContent: 'center', alignItems: 'center', + zIndex: 1000, + }} + > +
+
+
UPLOAD CANARY ARTIFACT
+ +
+ +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) setFile(f); + }} + style={{ + border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`, + padding: '32px', + textAlign: 'center', + marginBottom: '16px', + cursor: 'pointer', + background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent', + }} + onClick={() => document.getElementById('canary-blob-input')?.click()} + > + +
+ {file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'} +
+ {!file && ( +
+ DOCX · XLSX · PDF · HTML · PNG/JPEG · plain configs +
+ )} + setFile(e.target.files?.[0] || null)} + /> +
+ +
+ + DECNET injects the callback server-side; the original bytes stay on the master. +
+ + {error && ( +
{error}
+ )} + +
+ + +
+
+
+ ); +};