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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user