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:
Michilis
2026-07-26 06:48:05 +00:00
co-authored by Claude Opus 4.6
parent 93476ac72a
commit 617c884012
9 changed files with 453 additions and 64 deletions
+190
View File
@@ -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>
);
}