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:
Agent Zero
2026-09-15 23:16:30 +02:00
parent 7097e28578
commit 00f8f100d7
7 changed files with 452 additions and 2 deletions
+201
View File
@@ -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>
);
}