feat: photo visibility control with client access (#172)
Add two-tier gallery access system allowing clients (e.g., wedding couples) to review and hide photos before the gallery is shared with guests. Backend: - Migration 074: add visibility column to photos, client_access_enabled/ client_password_hash/client_share_token to events - Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN - Gallery photo list filters hidden photos for guests, shows all for clients - Visibility toggle endpoints (single + bulk) for client access level - Admin event CRUD supports client access fields - Email template includes client access link + PIN (EN/DE/RU/PT) Frontend: - ClientAccessPage: PIN entry form at /gallery/:slug/client-access - GalleryView: client mode banner, visibility counter, toggle controls - GridGalleryLayout: eye/eye-off overlay per photo for clients - AdminPhotoGrid: visibility badge, bulk Hide/Show buttons - EventDetailsPage: Client Access settings section (toggle, PIN, link) - CreateEventPage: client access toggle + PIN in event creation form - GalleryAuthContext: accessLevel/isClient/clientLogin support - New complete pt-BR locale (pt.json) with all translations - Client access i18n keys for EN, DE, RU, PT
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { AlertCircle, Lock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Card, CardContent, Input, Button, Loading } from '../components/common';
|
||||
import { useGalleryAuth } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { api } from '../config/api';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
|
||||
export const ClientAccessPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, isClient, clientLogin, isLoading: authLoading } = useGalleryAuth();
|
||||
const { t } = useTranslation();
|
||||
const [pin, setPin] = useState('');
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug);
|
||||
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['gallery-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// If already authenticated as client, redirect to gallery
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated && isClient && slug) {
|
||||
navigate(`/gallery/${slug}`, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, isClient, slug, navigate]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!pin.trim()) {
|
||||
setLoginError(t('clientAccess.enterPin'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
setLoginError(t('errors.galleryNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
await clientLogin(slug, pin);
|
||||
navigate(`/gallery/${slug}`, { replace: true });
|
||||
} catch (error: any) {
|
||||
const statusCode = error.response?.status;
|
||||
if (statusCode === 401) {
|
||||
setLoginError(t('clientAccess.invalidPin'));
|
||||
} else if (statusCode === 423) {
|
||||
setLoginError(t('auth.tooManyAttempts'));
|
||||
} else {
|
||||
setLoginError(t('clientAccess.loginFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingInfo || authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loading size="lg" text={t('gallery.loading')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (infoError || !galleryInfo) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
|
||||
<p className="text-neutral-600">{t('errors.galleryNotFoundMessage')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Logo */}
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-8 h-8 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{t('clientAccess.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-2">
|
||||
{galleryInfo.event_name}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-500 mt-1">
|
||||
{t('clientAccess.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
label={t('clientAccess.pinLabel')}
|
||||
placeholder={t('clientAccess.pinPlaceholder')}
|
||||
value={pin}
|
||||
onChange={(e) => {
|
||||
setPin(e.target.value);
|
||||
setLoginError(null);
|
||||
}}
|
||||
error={loginError || undefined}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
isLoading={isLoggingIn}
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
{t('clientAccess.loginButton')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 text-center">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('clientAccess.guestHint')}{' '}
|
||||
<Link
|
||||
to={`/gallery/${slug}`}
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
{t('clientAccess.guestLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
Palette,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Image
|
||||
Image,
|
||||
Key
|
||||
} from 'lucide-react';
|
||||
import { addDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -59,6 +60,9 @@ interface FormData {
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
};
|
||||
// Client access (#172)
|
||||
client_access_enabled: boolean;
|
||||
client_password: string;
|
||||
}
|
||||
|
||||
// Fallback event types (used when API is unavailable)
|
||||
@@ -114,8 +118,10 @@ export const CreateEventPage: React.FC = () => {
|
||||
rate_limit_window_minutes: 15,
|
||||
rate_limit_max_requests: 10,
|
||||
},
|
||||
client_access_enabled: false,
|
||||
client_password: '',
|
||||
});
|
||||
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
@@ -310,6 +316,9 @@ export const CreateEventPage: React.FC = () => {
|
||||
require_name_email: feedbackSettings.require_name_email,
|
||||
moderate_comments: feedbackSettings.moderate_comments,
|
||||
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
||||
// Client access (#172)
|
||||
client_access_enabled: formData.client_access_enabled,
|
||||
client_password: formData.client_access_enabled ? formData.client_password : undefined,
|
||||
};
|
||||
|
||||
createMutation.mutate(payload);
|
||||
@@ -742,6 +751,44 @@ export const CreateEventPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client Access (#172) */}
|
||||
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
checked={formData.client_access_enabled}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
client_access_enabled: e.target.checked,
|
||||
client_password: e.target.checked ? prev.client_password : '',
|
||||
}))}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('clientAccess.enableToggle')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('clientAccess.enableDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formData.client_access_enabled && (
|
||||
<div className="mt-3">
|
||||
<Input
|
||||
type="text"
|
||||
label={t('clientAccess.pinLabel')}
|
||||
placeholder={t('clientAccess.pinPlaceholder')}
|
||||
value={formData.client_password}
|
||||
onChange={handleInputChange('client_password')}
|
||||
leftIcon={<Key className="w-5 h-5" />}
|
||||
helperText={t('clientAccess.pinHelperText')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Upload Settings */}
|
||||
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<label className="flex items-center gap-3">
|
||||
|
||||
@@ -221,6 +221,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
rate_limit_max_requests: 10,
|
||||
});
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [copiedClientLink, setCopiedClientLink] = useState(false);
|
||||
const [clientPin, setClientPin] = useState('');
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [showExternalImport, setShowExternalImport] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||
@@ -263,7 +265,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
|
||||
|
||||
// Fetch event details
|
||||
const { data: event, isLoading: eventLoading } = useQuery({
|
||||
const { data: event, isLoading: eventLoading, refetch: refetchEvent } = useQuery({
|
||||
queryKey: ['admin-event', id],
|
||||
queryFn: () => eventsService.getEvent(parseInt(id!)),
|
||||
enabled: !!id,
|
||||
@@ -1533,7 +1535,135 @@ export const EventDetailsPage: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Client Access (#172) */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t('clientAccess.adminTitle')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
checked={!!event?.client_access_enabled}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await eventsService.updateEvent(event.id, { client_access_enabled: e.target.checked });
|
||||
refetchEvent();
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}}
|
||||
disabled={event?.is_archived}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('clientAccess.enableToggle')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('clientAccess.enableDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{event?.client_access_enabled && (
|
||||
<>
|
||||
{/* Set/Change PIN */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('clientAccess.pinLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={clientPin}
|
||||
onChange={(e) => setClientPin(e.target.value)}
|
||||
placeholder={t('clientAccess.pinPlaceholder')}
|
||||
className="w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<Key className="w-4 h-4" />}
|
||||
onClick={async () => {
|
||||
if (!clientPin.trim()) return;
|
||||
try {
|
||||
await eventsService.updateEvent(event.id, { client_password: clientPin });
|
||||
setClientPin('');
|
||||
toast.success(t('clientAccess.pinUpdated'));
|
||||
refetchEvent();
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}}
|
||||
disabled={!clientPin.trim()}
|
||||
>
|
||||
{t('clientAccess.setPin')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Client access link */}
|
||||
{event?.client_share_token && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('clientAccess.linkLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={copiedClientLink ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
onClick={async () => {
|
||||
const link = `${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = link;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
setCopiedClientLink(true);
|
||||
setTimeout(() => setCopiedClientLink(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copiedClientLink ? t('events.copied') : t('events.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await eventsService.updateEvent(event.id, { regenerate_client_token: true });
|
||||
toast.success(t('clientAccess.tokenRegenerated'));
|
||||
refetchEvent();
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('clientAccess.regenerateToken')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
{!event.is_archived && (
|
||||
|
||||
Reference in New Issue
Block a user