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
@@ -11,6 +11,7 @@ import {
setActiveGallerySlug,
storeGalleryToken,
} from '../utils/galleryAuthStorage';
import type { GalleryAccessLevel } from '../types';
interface GalleryEvent {
id: number;
@@ -37,7 +38,10 @@ const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent
interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
accessLevel: GalleryAccessLevel;
isClient: boolean;
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
clientLogin: (slug: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
error: string | null;
@@ -60,6 +64,7 @@ interface GalleryAuthProviderProps {
export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [event, setEvent] = useState<GalleryEvent | null>(null);
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<string | null>(null);
@@ -188,6 +193,14 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}
}
// Restore access level from session storage
const storedAccessLevel = sessionStorage.getItem(`gallery_access_level_${currentSlug}`);
if (storedAccessLevel === 'client') {
setAccessLevel('client');
} else {
setAccessLevel('guest');
}
const initialise = async () => {
try {
setIsLoading(true);
@@ -285,15 +298,43 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}
};
const clientLoginFn = async (slug: string, password: string) => {
try {
setRouteError(null);
setError(null);
setIsLoading(true);
const response = await authService.clientLogin(slug, password);
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
setAccessLevel(response.accessLevel || 'client');
sessionStorage.setItem(`gallery_access_level_${slug}`, 'client');
setIsAuthenticated(true);
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid PIN');
throw err;
} finally {
setIsLoading(false);
}
};
const logout = () => {
const currentSlug = routeInfo.slug;
if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
sessionStorage.removeItem(`gallery_access_level_${currentSlug}`);
clearGalleryToken(currentSlug);
}
authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false);
setEvent(null);
setAccessLevel('guest');
clearActiveGallerySlug();
};
@@ -302,7 +343,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
value={{
isAuthenticated,
event,
accessLevel,
isClient: accessLevel === 'client',
login,
clientLogin: clientLoginFn,
logout,
isLoading,
error: routeError ?? error,