Add view tokens for non-public gallery images and per-mode gate pages.
<img> tags cannot send Authorization headers, so non-public gallery photos were invisible even to authorized viewers. The server now mints short-lived HMAC view tokens (gallery-scoped, hour-bucketed) and embeds them in every file URL for non-public galleries. Access denials return distinct 403 messages per visibility mode, and the frontend renders a matching gate page (private, link-only, ticket-holders, login prompt) with an inline login modal so visitors never leave the gallery page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
93476ac72a
commit
617c884012
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||
import Lightbox from '@/components/Lightbox';
|
||||
import LoginModal from '@/components/LoginModal';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
CalendarIcon,
|
||||
CameraIcon,
|
||||
LinkIcon,
|
||||
LockClosedIcon,
|
||||
TicketIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
@@ -25,15 +27,13 @@ interface GalleryClientProps {
|
||||
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
|
||||
}
|
||||
|
||||
type DeniedState = 'login' | 'ticket' | 'notfound' | null;
|
||||
type DeniedState = 'login' | 'ticket' | 'private' | 'link' | 'notfound' | null;
|
||||
|
||||
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
|
||||
const { locale } = useLanguage();
|
||||
const es = locale === 'es';
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const shareToken = searchParams.get('token') || undefined;
|
||||
|
||||
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
|
||||
@@ -41,6 +41,7 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
||||
const [loading, setLoading] = useState(!initial);
|
||||
const [denied, setDenied] = useState<DeniedState>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
// Server-rendered public galleries need no client fetch. Everything else
|
||||
// (link/ticket/private) is fetched here with the share token and/or the
|
||||
@@ -62,9 +63,13 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return;
|
||||
// The photo-api returns a distinct message per visibility mode so
|
||||
// each gets its own gate page (see accessDenial in access.go).
|
||||
const msg = err.message || '';
|
||||
if (msg.includes('Authentication required')) setDenied('login');
|
||||
else if (msg.includes('attendees')) setDenied('ticket');
|
||||
else if (msg.includes('private')) setDenied('private');
|
||||
else if (msg.includes('share link')) setDenied('link');
|
||||
else setDenied('notfound');
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
@@ -83,44 +88,117 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
||||
);
|
||||
}
|
||||
|
||||
// Gate pages for restricted galleries. After a successful login in the
|
||||
// pop-up, AuthContext's user changes, which re-runs the fetch effect —
|
||||
// the gate resolves by itself when access is granted.
|
||||
const loginModal = (
|
||||
<LoginModal
|
||||
open={loginOpen}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
message={
|
||||
es
|
||||
? 'Inicia sesión para ver esta galería.'
|
||||
: 'Log in to view this gallery.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (denied === 'login') {
|
||||
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
|
||||
return (
|
||||
<GateMessage
|
||||
icon={LockClosedIcon}
|
||||
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
|
||||
body={
|
||||
es
|
||||
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
|
||||
: 'This gallery is for event attendees. Log in with the account you used to book.'
|
||||
}
|
||||
action={
|
||||
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}>
|
||||
{es ? 'Iniciar sesión' : 'Log in'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<GateMessage
|
||||
icon={TicketIcon}
|
||||
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||
body={
|
||||
es
|
||||
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
|
||||
: 'This gallery is for event attendees. Log in with the account you used to book.'
|
||||
}
|
||||
action={
|
||||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||
}
|
||||
/>
|
||||
{loginModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'ticket') {
|
||||
return (
|
||||
<GateMessage
|
||||
icon={TicketIcon}
|
||||
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||
body={
|
||||
es
|
||||
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.'
|
||||
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
|
||||
}
|
||||
action={
|
||||
eventSlug || gallery?.event ? (
|
||||
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
|
||||
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<GateMessage
|
||||
icon={TicketIcon}
|
||||
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||
body={
|
||||
es
|
||||
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada. ¿Reservaste con otra cuenta?'
|
||||
: 'This gallery is only available to people who attended the event with a confirmed ticket. Booked with a different account?'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{(eventSlug || gallery?.event) && (
|
||||
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
|
||||
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button onClick={() => setLoginOpen(true)}>
|
||||
{es ? 'Cambiar de cuenta' : 'Switch account'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{loginModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'private') {
|
||||
return (
|
||||
<>
|
||||
<GateMessage
|
||||
icon={LockClosedIcon}
|
||||
title={es ? 'Esta galería es privada' : 'This gallery is private'}
|
||||
body={
|
||||
es
|
||||
? 'Solo los organizadores pueden verla. Si eres parte del equipo, inicia sesión.'
|
||||
: 'Only the organizers can see it. If that’s you, log in.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/photos">
|
||||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||
</Link>
|
||||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{loginModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'link') {
|
||||
return (
|
||||
<>
|
||||
<GateMessage
|
||||
icon={LinkIcon}
|
||||
title={es ? 'Esta galería necesita su enlace' : 'This gallery needs its share link'}
|
||||
body={
|
||||
es
|
||||
? 'Solo se puede abrir con el enlace que compartieron los organizadores. Pídeles el enlace completo, o inicia sesión si eres parte del equipo.'
|
||||
: 'It can only be opened with the link the organizers shared. Ask them for the full link, or log in if you’re part of the team.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/photos">
|
||||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||
</Link>
|
||||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{loginModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface LoginModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful password login (Google logins reload the page). */
|
||||
onSuccess?: () => void;
|
||||
/** Optional context line shown under the title (e.g. why login is needed). */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable login pop-up: same capabilities as /login (password,
|
||||
* magic link, Google) but inline, so the visitor stays on the page —
|
||||
* used by gated photo galleries.
|
||||
*/
|
||||
export default function LoginModal({ open, onClose, onSuccess, message }: LoginModalProps) {
|
||||
const { t, locale } = useLanguage();
|
||||
const es = locale === 'es';
|
||||
const { login } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [mode, setMode] = useState<'password' | 'magic-link'>('password');
|
||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Google logins hard-reload; send them back to exactly where they are
|
||||
// (including a ?token= share link).
|
||||
const query = searchParams.toString();
|
||||
const currentUrl = query ? `${pathname}?${query}` : pathname;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
toast.success(es ? '¡Bienvenido!' : 'Welcome back!');
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('auth.errors.invalidCredentials'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMagicLink = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email) {
|
||||
toast.error(es ? 'Ingresa tu email' : 'Please enter your email');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await authApi.requestMagicLink(email);
|
||||
setMagicLinkSent(true);
|
||||
toast.success(es ? 'Revisa tu correo para el enlace de acceso' : 'Check your email for the login link');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : es ? 'Error' : 'Failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<Card className="w-full max-w-md p-6 md:p-8" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<h2 className="text-xl font-bold text-primary-dark">{t('auth.login.title')}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1 -m-1" aria-label="Close">
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
{message && <p className="text-sm text-gray-600 mb-4">{message}</p>}
|
||||
|
||||
<div className="mt-4">
|
||||
<GoogleSignInButton redirectTo={currentUrl} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<div className="flex-1 h-px bg-secondary-light-gray" />
|
||||
<span className="text-xs text-gray-500">{es ? 'o' : 'or'}</span>
|
||||
<div className="flex-1 h-px bg-secondary-light-gray" />
|
||||
</div>
|
||||
|
||||
{mode === 'password' ? (
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||||
<Input
|
||||
id="login-modal-email"
|
||||
label={t('auth.login.email')}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="login-modal-password"
|
||||
label={t('auth.login.password')}
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
{t('auth.login.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
) : magicLinkSent ? (
|
||||
<p className="text-sm text-gray-600 text-center py-4">
|
||||
{es
|
||||
? 'Te enviamos un enlace de acceso. Abre tu correo y vuelve a esta página.'
|
||||
: 'We sent you a login link. Open your email and come back to this page.'}
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleMagicLink} className="space-y-4">
|
||||
<Input
|
||||
id="login-modal-magic-email"
|
||||
label={t('auth.login.email')}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
{es ? 'Enviarme un enlace de acceso' : 'Email me a login link'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === 'password' ? 'magic-link' : 'password');
|
||||
setMagicLinkSent(false);
|
||||
}}
|
||||
className="text-secondary-blue hover:underline"
|
||||
>
|
||||
{mode === 'password'
|
||||
? es
|
||||
? 'Entrar con enlace por correo'
|
||||
: 'Log in with an email link'
|
||||
: es
|
||||
? 'Entrar con contraseña'
|
||||
: 'Log in with a password'}
|
||||
</button>
|
||||
<Link href="/register" className="text-secondary-blue hover:underline" onClick={onClose}>
|
||||
{es ? 'Crear cuenta' : 'Create account'}
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user