feat(public-share): Oeffentliche Share-Zugriffsseite fuer externe Besucher + SPA-Links im DMS-ShareDialog — Modul 13/16 des UI-Backlogs
This commit is contained in:
@@ -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> = {}): 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<typeof import('@/api/publicShare')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchPublicShareInfo: fetchInfo,
|
||||||
|
verifyPublicSharePassword: verifyPw,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSharePage(token = 'test-token-123') {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[`/share/${token}`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/share/:token" element={<PublicSharePage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PublicShareInfo> {
|
||||||
|
return apiGet<PublicShareInfo>(`/public/share/${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPublicSharePassword(token: string, password: string): Promise<ShareVerifyResult> {
|
||||||
|
// Backend takes the password as a plain query parameter (public endpoint,
|
||||||
|
// no auth — see public_routes.py verify_share_password).
|
||||||
|
return apiPost<ShareVerifyResult>(
|
||||||
|
`/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)}`;
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
type FilePermissionEntry,
|
type FilePermissionEntry,
|
||||||
type ShareLinkEntry,
|
type ShareLinkEntry,
|
||||||
} from '@/api/permissions';
|
} from '@/api/permissions';
|
||||||
|
import { publicSharePageUrl } from '@/api/publicShare';
|
||||||
|
|
||||||
export interface ShareDialogProps {
|
export interface ShareDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -269,7 +270,9 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
|||||||
{shareLinks.map((link) => (
|
{shareLinks.map((link) => (
|
||||||
<li key={link.id} className="flex items-center justify-between p-3 bg-secondary-50 rounded-md">
|
<li key={link.id} className="flex items-center justify-between p-3 bg-secondary-50 rounded-md">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<span className="text-sm text-secondary-700 truncate">{link.url}</span>
|
<span className="text-sm text-secondary-700 truncate" title={publicSharePageUrl(link.token)}>
|
||||||
|
{publicSharePageUrl(link.token)}
|
||||||
|
</span>
|
||||||
{link.password_protected && (
|
{link.password_protected && (
|
||||||
<Badge variant="warning">{t('permissions.passwordProtected')}</Badge>
|
<Badge variant="warning">{t('permissions.passwordProtected')}</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -283,7 +286,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCopyLink(link.url)}
|
onClick={() => handleCopyLink(publicSharePageUrl(link.token))}
|
||||||
>
|
>
|
||||||
{t('dms.copyLink')}
|
{t('dms.copyLink')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1949,5 +1949,23 @@
|
|||||||
"empty": "Keine Richtlinien für diesen Entitätstyp.",
|
"empty": "Keine Richtlinien für diesen Entitätstyp.",
|
||||||
"noPermission": "Keine Berechtigung zum Anzeigen der Richtlinien (policies:read erforderlich).",
|
"noPermission": "Keine Berechtigung zum Anzeigen der Richtlinien (policies:read erforderlich).",
|
||||||
"loadError": "Richtlinien konnten nicht geladen werden."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1949,5 +1949,23 @@
|
|||||||
"empty": "No policies for this entity type.",
|
"empty": "No policies for this entity type.",
|
||||||
"noPermission": "You lack permission to view policies (policies:read required).",
|
"noPermission": "You lack permission to view policies (policies:read required).",
|
||||||
"loadError": "Failed to load policies."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LoadState>({ status: 'loading' });
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-secondary-50 dark:bg-secondary-900 px-4" data-testid="public-share-page">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<ShieldCheck className="w-10 h-10 mx-auto text-primary-500" aria-hidden="true" />
|
||||||
|
<p className="text-sm text-secondary-500 mt-2">{t('publicShare.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.status === 'loading' && (
|
||||||
|
<div className="flex items-center justify-center py-12" role="status" data-testid="public-share-loading">
|
||||||
|
<span className="animate-spin h-8 w-8 border-2 border-primary-500 border-t-transparent rounded-full" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'error' && (
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-md p-8 text-center" data-testid="public-share-error">
|
||||||
|
<AlertTriangle className="w-10 h-10 mx-auto text-danger-500" aria-hidden="true" />
|
||||||
|
<h1 className="mt-3 text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||||
|
{state.code === 'not_found' && t('publicShare.notFoundTitle')}
|
||||||
|
{state.code === 'expired' && t('publicShare.expiredTitle')}
|
||||||
|
{state.code === 'load_failed' && t('publicShare.loadFailedTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-secondary-500">
|
||||||
|
{state.code === 'not_found' && t('publicShare.notFoundText')}
|
||||||
|
{state.code === 'expired' && t('publicShare.expiredText')}
|
||||||
|
{state.code === 'load_failed' && t('publicShare.loadFailedText')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(state.status === 'password_required' || state.status === 'unlocked') && (
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-md p-6" data-testid="public-share-card">
|
||||||
|
<div className="flex items-center gap-3 border-b border-secondary-200 dark:border-secondary-700 pb-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-primary-50 text-primary-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileText className="w-6 h-6" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-base font-semibold text-secondary-900 dark:text-secondary-100 truncate" data-testid="public-share-file-name">
|
||||||
|
{state.info.file_name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-secondary-500">
|
||||||
|
{formatSize(state.info.file_size)} · {state.info.mime_type || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.info.expires_at && (
|
||||||
|
<p className="mt-3 text-xs text-secondary-500 flex items-center gap-1" data-testid="public-share-expiry">
|
||||||
|
<Clock className="w-3 h-3" aria-hidden="true" />
|
||||||
|
{t('publicShare.expiresAt')}: {new Date(state.info.expires_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'password_required' && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-secondary-600 dark:text-secondary-300">
|
||||||
|
<Lock className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t('publicShare.passwordRequired')}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<p className="text-sm text-danger-600 dark:text-danger-400" data-testid="public-share-password-error">
|
||||||
|
{passwordError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={submitPassword}
|
||||||
|
disabled={!password.trim() || verifying}
|
||||||
|
className="w-full rounded-md bg-primary-600 text-white px-4 py-2.5 text-sm font-medium min-h-touch hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-testid="public-share-unlock-btn"
|
||||||
|
>
|
||||||
|
{verifying ? t('publicShare.verifying') : t('publicShare.unlock')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'unlocked' && (
|
||||||
|
<div className="mt-5 space-y-2" data-testid="public-share-unlocked">
|
||||||
|
<p className="text-sm text-success-600 dark:text-success-400 flex items-center gap-1.5">
|
||||||
|
<ShieldCheck className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t('publicShare.accessGranted')}
|
||||||
|
</p>
|
||||||
|
{state.info.access_level === 'download' || state.info.access_level === 'preview' ? (
|
||||||
|
<a
|
||||||
|
href={publicShareDownloadUrl(token)}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-md bg-primary-600 text-white px-4 py-2.5 text-sm font-medium min-h-touch hover:bg-primary-700"
|
||||||
|
data-testid="public-share-download-btn"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{t('publicShare.download')}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs text-secondary-400 text-center">
|
||||||
|
{t('publicShare.accessLevel')}: {state.info.access_level}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 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 ApiDocsPage = React.lazy(() => import('@/pages/ApiDocs').then(m => ({ default: m.ApiDocsPage })));
|
||||||
const SystemDashboardPage = React.lazy(() => import('@/pages/SystemDashboard').then(m => ({ default: m.SystemDashboardPage })));
|
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 */
|
/** Centered spinner fallback for lazy-loaded routes */
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
@@ -127,6 +128,10 @@ const router = createBrowserRouter([
|
|||||||
path: '/guest/contacts',
|
path: '/guest/contacts',
|
||||||
element: <GuestContactsPage />,
|
element: <GuestContactsPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/share/:token',
|
||||||
|
element: withSuspense(<PublicSharePage />),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/kein-zugriff',
|
path: '/kein-zugriff',
|
||||||
element: <ErrorBoundary>{withSuspense(<NoAccessPage />)}</ErrorBoundary>,
|
element: <ErrorBoundary>{withSuspense(<NoAccessPage />)}</ErrorBoundary>,
|
||||||
|
|||||||
Reference in New Issue
Block a user