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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user