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:
Paul Nothaft
2026-03-17 13:04:41 +01:00
parent 999c66dbbf
commit e1b6e43e52
25 changed files with 2217 additions and 1112 deletions
+207
View File
@@ -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>
);
};
+49 -2
View File
@@ -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">
+131 -1
View File
@@ -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 && (