diff --git a/frontend/src/__tests__/pages/PublicShare.test.tsx b/frontend/src/__tests__/pages/PublicShare.test.tsx new file mode 100644 index 0000000..3717cb4 --- /dev/null +++ b/frontend/src/__tests__/pages/PublicShare.test.tsx @@ -0,0 +1,157 @@ +/** + * PublicShare page tests — external share link access UI (UI-Backlog M13). + * + * Covers: loading, unlocked state with download, password flow (required, + * invalid, valid), error states (404 not found, 410 expired, other). + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { PublicSharePage } from '@/pages/PublicShare'; +import type { PublicShareInfo } from '@/api/publicShare'; + +const { fetchInfo, verifyPw } = vi.hoisted(() => ({ + fetchInfo: vi.fn(), + verifyPw: vi.fn(), +})); + +const makeInfo = (overrides: Partial = {}): PublicShareInfo => ({ + file_name: 'Angebot.pdf', + file_size: 102400, + mime_type: 'application/pdf', + access_level: 'download', + requires_password: false, + expires_at: null, + ...overrides, +}); + +vi.mock('@/api/publicShare', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchPublicShareInfo: fetchInfo, + verifyPublicSharePassword: verifyPw, + }; +}); + +function renderSharePage(token = 'test-token-123') { + return render( + + + } /> + + , + ); +} + +beforeEach(() => { + fetchInfo.mockReset(); + verifyPw.mockReset(); +}); + +describe('PublicSharePage', () => { + it('shows loading state initially', () => { + fetchInfo.mockReturnValue(new Promise(() => {})); + renderSharePage(); + expect(screen.getByTestId('public-share-loading')).toBeInTheDocument(); + }); + + it('shows unlocked file card with download link for unprotected shares', async () => { + fetchInfo.mockResolvedValue(makeInfo()); + renderSharePage(); + + await waitFor(() => { + expect(screen.getByTestId('public-share-unlocked')).toBeInTheDocument(); + }); + expect(screen.getByTestId('public-share-file-name')).toHaveTextContent('Angebot.pdf'); + const dl = screen.getByTestId('public-share-download-btn'); + expect(dl).toHaveAttribute('href', '/api/v1/public/share/test-token-123/download'); + }); + + it('shows expiry date when the link expires', async () => { + fetchInfo.mockResolvedValue(makeInfo({ expires_at: '2026-12-01T12:00:00Z' })); + renderSharePage(); + await waitFor(() => { + expect(screen.getByTestId('public-share-unlocked')).toBeInTheDocument(); + }); + expect(screen.getByTestId('public-share-expiry')).toBeInTheDocument(); + }); + + it('requires a password for protected shares and unlocks on valid password', async () => { + fetchInfo.mockResolvedValue(makeInfo({ requires_password: true })); + verifyPw.mockResolvedValue({ valid: true }); + renderSharePage(); + + await waitFor(() => { + expect(screen.getByTestId('public-share-password-input')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('public-share-download-btn')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('public-share-password-input'), { + target: { value: 'geheim123' }, + }); + fireEvent.click(screen.getByTestId('public-share-unlock-btn')); + + await waitFor(() => { + expect(screen.getByTestId('public-share-unlocked')).toBeInTheDocument(); + }); + expect(verifyPw).toHaveBeenCalledWith('test-token-123', 'geheim123'); + expect(screen.getByTestId('public-share-download-btn')).toBeInTheDocument(); + }); + + it('shows an error message on invalid password', async () => { + fetchInfo.mockResolvedValue(makeInfo({ requires_password: true })); + verifyPw.mockRejectedValue(new Error('403')); + renderSharePage(); + + await waitFor(() => { + expect(screen.getByTestId('public-share-password-input')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByTestId('public-share-password-input'), { + target: { value: 'falsch' }, + }); + fireEvent.click(screen.getByTestId('public-share-unlock-btn')); + + await waitFor(() => { + expect(screen.getByTestId('public-share-password-error')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('public-share-unlocked')).not.toBeInTheDocument(); + }); + + it('shows not-found error for 404 responses', async () => { + fetchInfo.mockRejectedValue({ response: { status: 404 } }); + renderSharePage(); + await waitFor(() => { + expect(screen.getByTestId('public-share-error')).toBeInTheDocument(); + }); + expect(screen.getByText('Link nicht gefunden')).toBeInTheDocument(); + }); + + it('shows expired error for 410 responses', async () => { + fetchInfo.mockRejectedValue({ response: { status: 410 } }); + renderSharePage(); + await waitFor(() => { + expect(screen.getByTestId('public-share-error')).toBeInTheDocument(); + }); + expect(screen.getByText('Link abgelaufen')).toBeInTheDocument(); + }); + + it('shows generic error for other failures', async () => { + fetchInfo.mockRejectedValue(new Error('network')); + renderSharePage(); + await waitFor(() => { + expect(screen.getByTestId('public-share-error')).toBeInTheDocument(); + }); + expect(screen.getByText('Fehler')).toBeInTheDocument(); + }); + + it('unlock button stays disabled without a password', async () => { + fetchInfo.mockResolvedValue(makeInfo({ requires_password: true })); + renderSharePage(); + await waitFor(() => { + expect(screen.getByTestId('public-share-unlock-btn')).toBeInTheDocument(); + }); + expect(screen.getByTestId('public-share-unlock-btn')).toBeDisabled(); + }); +}); diff --git a/frontend/src/api/publicShare.ts b/frontend/src/api/publicShare.ts new file mode 100644 index 0000000..b7491cf --- /dev/null +++ b/frontend/src/api/publicShare.ts @@ -0,0 +1,48 @@ +/** + * Public share API client — token-based access to shared files (no auth). + * + * Backend: /api/v1/public/share (permissions plugin, public routes) + * - GET /{token} → share info (file name/size/mime, password flag) + * - POST /{token}/verify → verify password (query param `password`) + * - GET /{token}/download → file download (StreamingResponse — use direct + * navigation, not the JSON api client) + * + * Used by the public /share/:token page for external visitors. + */ + +import { apiGet, apiPost } from '@/api/client'; + +export interface PublicShareInfo { + file_name: string; + file_size: number; + mime_type: string; + access_level: string; + requires_password: boolean; + expires_at: string | null; +} + +export interface ShareVerifyResult { + valid: boolean; +} + +export function fetchPublicShareInfo(token: string): Promise { + return apiGet(`/public/share/${encodeURIComponent(token)}`); +} + +export function verifyPublicSharePassword(token: string, password: string): Promise { + // Backend takes the password as a plain query parameter (public endpoint, + // no auth — see public_routes.py verify_share_password). + return apiPost( + `/public/share/${encodeURIComponent(token)}/verify?password=${encodeURIComponent(password)}`, + ); +} + +/** Direct download URL for the file behind a share token. */ +export function publicShareDownloadUrl(token: string): string { + return `/api/v1/public/share/${encodeURIComponent(token)}/download`; +} + +/** SPA URL for a share token (shown in the DMS share dialog). */ +export function publicSharePageUrl(token: string): string { + return `${window.location.origin}/share/${encodeURIComponent(token)}`; +} diff --git a/frontend/src/components/dms/ShareDialog.tsx b/frontend/src/components/dms/ShareDialog.tsx index 33f6c17..71f0c46 100644 --- a/frontend/src/components/dms/ShareDialog.tsx +++ b/frontend/src/components/dms/ShareDialog.tsx @@ -31,6 +31,7 @@ import { type FilePermissionEntry, type ShareLinkEntry, } from '@/api/permissions'; +import { publicSharePageUrl } from '@/api/publicShare'; export interface ShareDialogProps { open: boolean; @@ -269,7 +270,9 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps) {shareLinks.map((link) => (
  • - {link.url} + + {publicSharePageUrl(link.token)} + {link.password_protected && ( {t('permissions.passwordProtected')} )} @@ -283,7 +286,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e91d3b7..0505ca7 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1949,5 +1949,23 @@ "empty": "Keine Richtlinien für diesen Entitätstyp.", "noPermission": "Keine Berechtigung zum Anzeigen der Richtlinien (policies:read erforderlich).", "loadError": "Richtlinien konnten nicht geladen werden." + }, + "publicShare": { + "subtitle": "Geteilte Datei über öffentlichen Link", + "passwordRequired": "Diese Datei ist passwortgeschützt.", + "passwordPlaceholder": "Passwort eingeben…", + "unlock": "Entsperren", + "verifying": "Prüfe…", + "invalidPassword": "Falsches Passwort.", + "accessGranted": "Zugriff gewährt", + "download": "Datei herunterladen", + "accessLevel": "Zugriffsstufe", + "expiresAt": "Läuft ab", + "notFoundTitle": "Link nicht gefunden", + "notFoundText": "Dieser Link ist ungültig oder wurde widerrufen.", + "expiredTitle": "Link abgelaufen", + "expiredText": "Dieser Link ist am angegebenen Datum abgelaufen.", + "loadFailedTitle": "Fehler", + "loadFailedText": "Die Freigabe konnte nicht geladen werden." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9ea6057..5a49ba8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1949,5 +1949,23 @@ "empty": "No policies for this entity type.", "noPermission": "You lack permission to view policies (policies:read required).", "loadError": "Failed to load policies." + }, + "publicShare": { + "subtitle": "Shared file via public link", + "passwordRequired": "This file is password protected.", + "passwordPlaceholder": "Enter password…", + "unlock": "Unlock", + "verifying": "Verifying…", + "invalidPassword": "Invalid password.", + "accessGranted": "Access granted", + "download": "Download file", + "accessLevel": "Access level", + "expiresAt": "Expires", + "notFoundTitle": "Link not found", + "notFoundText": "This link is invalid or has been revoked.", + "expiredTitle": "Link expired", + "expiredText": "This link expired on the given date.", + "loadFailedTitle": "Error", + "loadFailedText": "Failed to load the share." } } diff --git a/frontend/src/pages/PublicShare.tsx b/frontend/src/pages/PublicShare.tsx new file mode 100644 index 0000000..85b7ef7 --- /dev/null +++ b/frontend/src/pages/PublicShare.tsx @@ -0,0 +1,201 @@ +/** + * Public share access page — external visitors open share links via + * /share/:token (no login required). Shows the shared file's info, + * asks for the share password when needed, and offers the download. + * + * Backend: /api/v1/public/share/{token} (permissions plugin public routes). + */ + +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + FileText, + Download, + Lock, + AlertTriangle, + Clock, + ShieldCheck, +} from 'lucide-react'; +import { + fetchPublicShareInfo, + verifyPublicSharePassword, + publicShareDownloadUrl, + type PublicShareInfo, +} from '@/api/publicShare'; + +function formatSize(bytes: number): string { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +} + +type LoadState = + | { status: 'loading' } + | { status: 'error'; code: 'not_found' | 'expired' | 'load_failed' } + | { status: 'password_required'; info: PublicShareInfo } + | { status: 'unlocked'; info: PublicShareInfo }; + +export function PublicSharePage() { + const { t } = useTranslation(); + const { token = '' } = useParams<{ token: string }>(); + const [state, setState] = useState({ status: 'loading' }); + const [password, setPassword] = useState(''); + const [verifying, setVerifying] = useState(false); + const [passwordError, setPasswordError] = useState(null); + + useEffect(() => { + let cancelled = false; + setState({ status: 'loading' }); + fetchPublicShareInfo(token) + .then((info) => { + if (cancelled) return; + if (info.requires_password) { + setState({ status: 'password_required', info }); + } else { + setState({ status: 'unlocked', info }); + } + }) + .catch((err: unknown) => { + if (cancelled) return; + const status = (err as { response?: { status?: number } })?.response?.status; + if (status === 404) setState({ status: 'error', code: 'not_found' }); + else if (status === 410) setState({ status: 'error', code: 'expired' }); + else setState({ status: 'error', code: 'load_failed' }); + }); + return () => { + cancelled = true; + }; + }, [token]); + + const submitPassword = () => { + if (!password.trim()) return; + setVerifying(true); + setPasswordError(null); + verifyPublicSharePassword(token, password) + .then((result) => { + if (result.valid) { + setState((s) => (s.status === 'password_required' ? { status: 'unlocked', info: s.info } : s)); + } else { + setPasswordError(t('publicShare.invalidPassword')); + } + }) + .catch(() => { + setPasswordError(t('publicShare.invalidPassword')); + }) + .finally(() => setVerifying(false)); + }; + + return ( +
    +
    +
    +
    + + {state.status === 'loading' && ( +
    +
    + )} + + {state.status === 'error' && ( +
    +
    + )} + + {(state.status === 'password_required' || state.status === 'unlocked') && ( +
    +
    +
    +
    +
    +

    + {state.info.file_name} +

    +

    + {formatSize(state.info.file_size)} · {state.info.mime_type || '—'} +

    +
    +
    + + {state.info.expires_at && ( +

    +

    + )} + + {state.status === 'password_required' && ( +
    +
    +
    + setPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submitPassword()} + className="w-full rounded-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-3 py-2 text-sm min-h-touch" + placeholder={t('publicShare.passwordPlaceholder')} + aria-label={t('publicShare.passwordPlaceholder')} + data-testid="public-share-password-input" + /> + {passwordError && ( +

    + {passwordError} +

    + )} + +
    + )} + + {state.status === 'unlocked' && ( +
    +

    +

    + {state.info.access_level === 'download' || state.info.access_level === 'preview' ? ( + + + ) : null} +

    + {t('publicShare.accessLevel')}: {state.info.access_level} +

    +
    + )} +
    + )} +
    +
    + ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index bf778c3..dc57b5e 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -75,6 +75,7 @@ const LogsPlaceholderPage = React.lazy(() => import('@/pages/logs/LogsPlaceholde const HelpApiDocsPage = React.lazy(() => import('@/pages/help/HelpApiDocs').then(m => ({ default: m.HelpApiDocsPage }))); const ApiDocsPage = React.lazy(() => import('@/pages/ApiDocs').then(m => ({ default: m.ApiDocsPage }))); const SystemDashboardPage = React.lazy(() => import('@/pages/SystemDashboard').then(m => ({ default: m.SystemDashboardPage }))); +const PublicSharePage = React.lazy(() => import('@/pages/PublicShare').then(m => ({ default: m.PublicSharePage }))); /** Centered spinner fallback for lazy-loaded routes */ function PageLoader() { @@ -127,6 +128,10 @@ const router = createBrowserRouter([ path: '/guest/contacts', element: , }, + { + path: '/share/:token', + element: withSuspense(), + }, { path: '/kein-zugriff', element: {withSuspense()},