dorfteich/apps/web/src/files/AttachmentsPanel.tsx
Claude Fable 5 868b79c8bc
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m15s
CI / Build container images (pull_request) Successful in 4m27s
CI / Auth e2e pack (pull_request) Successful in 9m10s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 20s
CI / Lint, typecheck, test (push) Successful in 5m47s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m26s
CI / Import/export fidelity gate (push) Successful in 1m0s
#213: warn on uploads to classified pages; instance policy can block
The attachments panel of a classified page shows a persistent notice
naming the consequence (de+en): the file inherits the page's
classification but its content carries no marking (#212). The new
instance setting classification.uploadPolicy (default warn, documented;
the VS-NfD reference configuration blocks, #227) hardens the warning
into a server-side rejection (403 classified_upload_blocked) — enforced
in the upload service, not only in the UI. Tests: warning visible in the
local attachments pack; block enforced server-side with warn/block both
ways and open pages unaffected.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:33:34 +02:00

177 lines
5.2 KiB
TypeScript

import type { AttachmentListItemView, PageClassification } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { Editor } from '@tiptap/react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiUploadFile } from '../lib/api';
import { fileGlyph, formatBytes, mediaUrl } from './file-format';
/**
* A page's attachments section (issue #61): upload a file, see it listed with
* its type icon, size, and uploader, insert it into the document as a download
* link, or delete it. Uploads and deletes are page-write-gated in the api; in
* read mode the panel is a download list (no upload/insert/delete controls).
*/
export function AttachmentsPanel({
pageId,
editor,
canEdit,
classification,
onClose,
}: {
pageId: string;
editor: Editor | null;
canEdit: boolean;
/** VS-NfD level of the page (issue #213): a classified page shows the
* upload warning naming the consequence — attachments inherit a level
* their content cannot carry (#212). */
classification?: PageClassification;
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation('files');
const queryClient = useQueryClient();
const fileInput = useRef<HTMLInputElement>(null);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const list = useQuery({
queryKey: ['page-files', pageId],
queryFn: () => apiGet<AttachmentListItemView[]>(`/pages/${pageId}/files`),
});
async function refresh(): Promise<void> {
await queryClient.invalidateQueries({ queryKey: ['page-files', pageId] });
}
async function onPick(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setError(null);
setBusy(true);
try {
await apiUploadFile(`/pages/${pageId}/files`, file);
await refresh();
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
}
async function remove(id: string): Promise<void> {
setError(null);
try {
await apiDelete(`/files/${id}`);
await refresh();
} catch (err) {
setError(err);
}
}
function insertLink(item: AttachmentListItemView): void {
if (!editor) return;
editor
.chain()
.focus()
.insertContent([
{
type: 'text',
text: item.fileName,
marks: [{ type: 'link', attrs: { href: mediaUrl(item.id) } }],
},
{ type: 'text', text: ' ' },
])
.run();
}
const items = list.data ?? [];
return (
<aside className="attachments-panel" aria-label={t('title')}>
<div className="attachments-panel__header">
<h3>{t('title')}</h3>
<button type="button" className="button" onClick={onClose}>
{t('close')}
</button>
</div>
<FormError error={error} />
{canEdit && classification === 'vs_nfd' && (
<p className="attachments-panel__warning" role="note">
{t('classifiedUploadWarning')}
</p>
)}
{canEdit && (
<div className="attachments-panel__upload">
<input
ref={fileInput}
type="file"
className="attachments-panel__input"
onChange={(event) => void onPick(event)}
/>
<button
type="button"
className="button"
disabled={busy}
onClick={() => fileInput.current?.click()}
>
{busy ? t('uploading') : t('upload')}
</button>
</div>
)}
{items.length === 0 ? (
<p className="attachments-panel__empty">{t('empty')}</p>
) : (
<ul className="attachments-panel__list">
{items.map((item) => (
<li key={item.id} className="attachments-item">
<span className="attachments-item__glyph" aria-hidden="true">
{fileGlyph(item.fileName, item.mimeType)}
</span>
<a
className="attachments-item__name"
href={mediaUrl(item.id)}
target="_blank"
rel="noreferrer"
download={item.fileName}
>
{item.fileName}
</a>
<span className="attachments-item__meta">
{formatBytes(item.sizeBytes)} · {item.uploaderName}
</span>
{canEdit && (
<span className="attachments-item__actions">
{editor && (
<button
type="button"
className="button attachments-item__insert"
onClick={() => insertLink(item)}
>
{t('insert')}
</button>
)}
<button
type="button"
className="button attachments-item__delete"
onClick={() => void remove(item.id)}
>
{t('delete')}
</button>
</span>
)}
</li>
))}
</ul>
)}
</aside>
);
}