feat: support per-gallery password toggle

This commit is contained in:
2025-10-01 14:18:14 +02:00
parent 45e835a51a
commit 5d6c061f1c
23 changed files with 1019 additions and 349 deletions
+35 -9
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { api } from '../config/api';
import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
import { normalizeRequirePassword } from '../utils/accessControl';
import {
clearActiveGallerySlug,
clearGalleryToken,
@@ -18,12 +19,24 @@ interface GalleryEvent {
welcome_message?: string;
color_theme?: string;
expires_at: string;
require_password?: boolean;
}
const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent | null => {
if (!incoming) {
return null;
}
return {
...incoming,
require_password: normalizeRequirePassword(incoming.require_password, true),
};
};
interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
logout: () => void;
isLoading: boolean;
error: string | null;
@@ -83,7 +96,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
try {
const parsed = JSON.parse(storedEvent);
if (parsed && parsed.id) {
setEvent(parsed);
const normalizedStored = normalizeEvent(parsed);
setEvent(normalizedStored);
if (normalizedStored) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
}
}
} catch (err) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
@@ -104,8 +121,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
// Fetch gallery details to hydrate context
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) {
setEvent(galleryData.event);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event));
const normalizedEvent = normalizeEvent(galleryData.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
}
}
@@ -121,9 +141,12 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
if (verify?.valid) {
const response = await authService.shareLinkLogin(currentSlug, urlToken);
if (response?.event) {
setEvent(response.event);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
setIsAuthenticated(true);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
if (response.token) {
storeGalleryToken(currentSlug, response.token);
}
@@ -154,12 +177,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
};
}, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
try {
setError(null);
setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
setIsAuthenticated(true);
if (response.token) {
storeGalleryToken(slug, response.token);
@@ -167,7 +191,9 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive)
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid password');
throw err;
+10 -1
View File
@@ -496,6 +496,8 @@
"expiresIn": "Galerie läuft in {{count}} Tag ab",
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
"publicGalleryTitle": "Diese Galerie ist öffentlich zugänglich",
"publicGallerySubtitle": "Fotos werden geladen...",
"viewGallery": "Galerie anzeigen",
"downloadAll": "Alle herunterladen",
"downloading": "Lade {{count}} Foto herunter...",
@@ -607,6 +609,7 @@
"created": "Erstellt",
"expires": "Läuft ab",
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
"shareWithGuestsPublic": "Teilen Sie diesen Link mit Gästen. Für diese Galerie ist kein Passwort erforderlich.",
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
@@ -632,10 +635,14 @@
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
"securityAccess": "Sicherheit & Zugriff",
"galleryPassword": "Galerie-Passwort",
"requirePasswordToggle": "Galerie mit Passwort schützen",
"requirePasswordToggleHelp": "Deaktivieren Sie diese Option, wenn die Galerie ohne Passwort geteilt werden soll. Jeder mit dem Link kann die Fotos ansehen.",
"publicGalleryWarning": "Öffentliche Galerien sind für jeden mit dem Link zugänglich. Aktivieren Sie gegebenenfalls Wasserzeichen und behalten Sie die Aktivität im Blick.",
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
"passwordPlaceholder": "Sicheres Passwort eingeben",
"confirmPassword": "Passwort bestätigen",
"showPasswords": "Passwörter anzeigen",
"newPasswordLabel": "Neues Galerie-Passwort",
"gallerySettings": "Galerie-Einstellungen",
"colorTheme": "Farbthema",
"galleryExpiration": "Galerie-Ablauf",
@@ -735,6 +742,9 @@
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
"publicAccess": "Öffentlicher Zugriff",
"passwordProtected": "Passwortgeschützt",
"newPasswordRequired": "Bitte legen Sie vor dem Aktivieren des Passwortschutzes ein Passwort fest.",
"viewDetails": "Details anzeigen",
"archiveEventAction": "Veranstaltung archivieren",
"downloadArchiveAction": "Archiv herunterladen",
@@ -853,7 +863,6 @@
"security": {
"title": "Sicherheit",
"passwordSettings": "Passworteinstellungen",
"requirePassword": "Passwort für alle Galerien erforderlich",
"minPasswordLength": "Minimale Passwortlänge",
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
"passwordComplexity": "Passwort-Komplexität",
+10 -1
View File
@@ -161,6 +161,8 @@
"expiresIn": "Gallery expires in {{count}} day",
"expiresIn_plural": "Gallery expires in {{count}} days",
"downloadBefore": "Download your photos before they're no longer available.",
"publicGalleryTitle": "This gallery is publicly accessible",
"publicGallerySubtitle": "Loading the photos now...",
"viewGallery": "View Gallery",
"downloadAll": "Download All",
"downloading": "Downloading {{count}} photo...",
@@ -290,6 +292,7 @@
"created": "Created",
"expires": "Expires",
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
"shareWithGuestsPublic": "Share this link with guests. No password is required for this gallery.",
"resetGalleryPassword": "Reset Gallery Password",
"resendCreationEmail": "Resend Creation Email",
"creationEmailResent": "Creation email has been queued for sending",
@@ -316,9 +319,13 @@
"adminEmailHelp": "Will receive system notifications and archive confirmations",
"securityAccess": "Security & Access",
"galleryPassword": "Gallery Password",
"requirePasswordToggle": "Require password for this gallery",
"requirePasswordToggleHelp": "Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.",
"publicGalleryWarning": "Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.",
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
"confirmPassword": "Confirm Password",
"showPasswords": "Show passwords",
"newPasswordLabel": "New Gallery Password",
"gallerySettings": "Gallery Settings",
"themeAndStyle": "Theme & Style",
"colorTheme": "Color Theme",
@@ -373,6 +380,9 @@
"eventsSelected_plural": "{{count}} events selected",
"clear": "Clear",
"archiveSelected": "Archive Selected",
"publicAccess": "Public access",
"passwordProtected": "Password protected",
"newPasswordRequired": "Please set a password before enabling protection.",
"event": "Event",
"type": "Type",
"date": "Date",
@@ -533,7 +543,6 @@
"security": {
"title": "Security",
"passwordSettings": "Password Settings",
"requirePassword": "Require password for all galleries",
"minPasswordLength": "Minimum Password Length",
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
"passwordComplexity": "Password Complexity",
+95 -47
View File
@@ -14,6 +14,7 @@ import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
@@ -25,9 +26,11 @@ export const GalleryPage: React.FC = () => {
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
// Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
// Fetch branding settings
const { data: settingsData } = useQuery({
@@ -87,6 +90,30 @@ export const GalleryPage: React.FC = () => {
}
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
React.useEffect(() => {
if (!slug) {
return;
}
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
setAutoLoginAttempted(true);
setIsLoggingIn(true);
login(slug, '')
.then(() => {
setLoginError(null);
})
.catch((error: any) => {
const message = error?.response?.data?.error;
if (message) {
setLoginError(message);
}
})
.finally(() => {
setIsLoggingIn(false);
});
}
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
@@ -96,7 +123,7 @@ export const GalleryPage: React.FC = () => {
e.preventDefault();
e.stopPropagation(); // Prevent any bubbling
if (!password.trim()) {
if (requiresPassword && !password.trim()) {
setLoginError(t('auth.pleaseEnterPassword'));
return;
}
@@ -104,13 +131,14 @@ export const GalleryPage: React.FC = () => {
try {
setIsLoggingIn(true);
setLoginError(null);
await login(slug!, password, recaptchaToken);
await login(slug!, requiresPassword ? password : '', recaptchaToken);
// Track successful password entry
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
success: true
});
if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
success: true
});
}
} catch (error: any) {
console.error('Login error:', error);
const errorMessage = error.response?.data?.error || 'Invalid password';
@@ -128,11 +156,13 @@ export const GalleryPage: React.FC = () => {
}
// Track failed password entry
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
success: false,
statusCode
});
if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
success: false,
statusCode
});
}
// Keep the password field to allow retry
// Do not clear the password
@@ -311,43 +341,61 @@ export const GalleryPage: React.FC = () => {
</div>
)}
{/* Login Card */}
<Card>
<CardContent className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
autoFocus
className="text-sm sm:text-base"
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-sm sm:text-base"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
{t('gallery.viewGallery')}
</Button>
</form>
{requiresPassword ? (
<>
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
autoFocus
className="text-sm sm:text-base"
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-sm sm:text-base"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
{t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
</>
) : (
<div className="text-center space-y-3">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold">
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
</h2>
<p className="text-sm text-neutral-600">
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
</p>
<div className="flex justify-center py-4">
<Loading size="sm" text={t('gallery.loading')} />
</div>
{loginError && (
<p className="text-xs text-red-600">{loginError}</p>
)}
</div>
)}
</CardContent>
</Card>
@@ -376,4 +424,4 @@ export const GalleryPage: React.FC = () => {
</div>
</div>
);
};
};
+116 -84
View File
@@ -27,6 +27,7 @@ interface FormData {
event_date: string;
host_email: string;
admin_email: string;
require_password: boolean;
password: string;
confirm_password: string;
welcome_message: string;
@@ -123,6 +124,7 @@ export const CreateEventPage: React.FC = () => {
event_date: format(new Date(), 'yyyy-MM-dd'),
host_email: '',
admin_email: '',
require_password: true,
password: '',
confirm_password: '',
welcome_message: '',
@@ -208,17 +210,18 @@ export const CreateEventPage: React.FC = () => {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
if (!formData.password) {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
// Prevent simple numeric passwords like "123456"
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.require_password) {
if (!formData.password) {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
}
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
@@ -244,7 +247,8 @@ export const CreateEventPage: React.FC = () => {
event_date: formData.event_date,
host_email: formData.host_email,
admin_email: formData.admin_email,
password: formData.password,
require_password: formData.require_password,
password: formData.require_password ? formData.password : '',
welcome_message: formData.welcome_message || '',
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
expiration_days: formData.expires_in_days,
@@ -426,81 +430,109 @@ export const CreateEventPage: React.FC = () => {
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.galleryPassword')}
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
placeholder={t('events.enterPassword')}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
<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 rounded focus:ring-primary-500"
checked={formData.require_password}
onChange={(e) => {
const checked = e.target.checked;
setFormData(prev => ({ ...prev, require_password: checked }));
if (!checked) {
setErrors(prev => ({ ...prev, password: '', confirm_password: '' }));
}
}}
/>
<div>
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
</p>
</div>
{/* Password Generator */}
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
className="w-full"
/>
</div>
</div>
</label>
{/* Confirm Password */}
<div>
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirm_password"
type={showPassword ? 'text' : 'password'}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
placeholder={t('events.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.confirm_password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
{!formData.require_password && (
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
</div>
</div>
)}
{formData.require_password && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.galleryPassword')}
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
placeholder={t('events.enterPassword')}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div>
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
className="w-full"
/>
</div>
</div>
<div>
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirm_password"
type={showPassword ? 'text' : 'password'}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
placeholder={t('events.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.confirm_password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div>
</div>
</div>
)}
</div>
</Card>
@@ -645,4 +677,4 @@ export const CreateEventPage: React.FC = () => {
);
};
CreateEventPage.displayName = 'CreateEventPage';
CreateEventPage.displayName = 'CreateEventPage';
@@ -30,6 +30,7 @@ interface FormData {
host_name: string;
host_email: string;
admin_email: string;
require_password: boolean;
password: string;
confirm_password: string;
welcome_message: string;
@@ -88,6 +89,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
host_name: '',
host_email: '',
admin_email: '',
require_password: true,
password: '',
confirm_password: '',
welcome_message: '',
@@ -198,17 +200,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
if (!formData.password) {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
// Prevent simple numeric passwords like "123456"
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.require_password) {
if (!formData.password) {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
// Prevent simple numeric passwords like "123456"
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
}
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
@@ -235,7 +239,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
host_name: formData.host_name,
host_email: formData.host_email,
admin_email: formData.admin_email,
password: formData.password,
require_password: formData.require_password,
password: formData.require_password ? formData.password : '',
welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days,
@@ -495,51 +500,87 @@ export const CreateEventPageEnhanced: React.FC = () => {
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="space-y-3">
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
checked={formData.require_password}
onChange={(e) => {
const checked = e.target.checked;
setFormData(prev => ({
...prev,
require_password: checked,
}));
if (!checked) {
setErrors(prev => ({ ...prev, password: undefined, confirm_password: undefined }));
}
}}
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('events.requirePasswordToggle')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
</p>
</div>
</label>
{!formData.require_password && (
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
</div>
)}
</div>
{formData.require_password && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.galleryPassword')}
placeholder={t('events.passwordPlaceholder')}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
{/* Password Generator */}
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity="moderate"
className="w-full"
/>
</div>
</div>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.galleryPassword')}
placeholder={t('events.passwordPlaceholder')}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
label={t('events.confirmPassword')}
placeholder={t('events.confirmPasswordPlaceholder')}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
{/* Password Generator */}
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity="moderate"
className="w-full"
/>
</div>
</div>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.confirmPassword')}
placeholder={t('events.confirmPasswordPlaceholder')}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
leftIcon={<Lock className="w-5 h-5" />}
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
+132 -2
View File
@@ -17,7 +17,10 @@ import {
Image,
Key,
Mail,
MessageSquare
MessageSquare,
Lock,
Eye,
EyeOff
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
@@ -27,6 +30,7 @@ import { Button, Input, Card, Loading } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
@@ -121,6 +125,9 @@ export const EventDetailsPage: React.FC = () => {
host_name: string;
source_mode: 'managed' | 'reference';
external_path: string;
require_password: boolean;
new_password: string;
confirm_new_password: string;
};
const [isEditing, setIsEditing] = useState(false);
@@ -134,6 +141,9 @@ export const EventDetailsPage: React.FC = () => {
host_name: '',
source_mode: 'managed',
external_path: '',
require_password: true,
new_password: '',
confirm_new_password: '',
});
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false,
@@ -156,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
const [importing, setImporting] = useState<boolean>(false);
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
@@ -274,7 +285,12 @@ export const EventDetailsPage: React.FC = () => {
host_name: event.host_name || '',
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
external_path: event.external_path || '',
require_password: normalizeRequirePassword(event.require_password),
new_password: '',
confirm_new_password: '',
});
setShowNewPassword(false);
// Set feedback settings if available
if (eventFeedbackSettings) {
@@ -324,6 +340,26 @@ export const EventDetailsPage: React.FC = () => {
const externalPathToSave = editForm.external_path?.trim() || '';
const currentRequirePassword = normalizeRequirePassword(event.require_password);
const requirePasswordChanged = editForm.require_password !== currentRequirePassword;
if (editForm.require_password) {
if (requirePasswordChanged && !editForm.new_password) {
toast.error(t('events.newPasswordRequired', 'Please set a password before enabling protection.'));
return;
}
if (editForm.new_password) {
if (editForm.new_password.length < 6) {
toast.error(t('validation.passwordMinLength'));
return;
}
if (editForm.new_password !== editForm.confirm_new_password) {
toast.error(t('validation.passwordsDoNotMatch'));
return;
}
}
}
if (editForm.source_mode === 'reference' && !externalPathToSave) {
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
return;
@@ -333,6 +369,7 @@ export const EventDetailsPage: React.FC = () => {
const updateData: any = {
expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
};
// Only include fields that have defined values
@@ -355,6 +392,10 @@ export const EventDetailsPage: React.FC = () => {
if (editForm.host_name !== undefined && editForm.host_name !== null) {
updateData.host_name = editForm.host_name;
}
if (editForm.new_password) {
updateData.password = editForm.new_password;
}
// Remove any keys with undefined values
Object.keys(updateData).forEach(key => {
@@ -437,6 +478,15 @@ export const EventDetailsPage: React.FC = () => {
{format(parseISO(event.event_date), 'PPP')}
</span>
<span className="capitalize">{event.event_type}</span>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
isGalleryPublic(event.require_password)
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-700'
}`}
>
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
</span>
{event.is_archived ? (
<span className="text-neutral-500 flex items-center">
<Archive className="w-4 h-4 mr-1" />
@@ -641,6 +691,84 @@ export const EventDetailsPage: React.FC = () => {
isEditing={isEditing}
/>
<div>
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
checked={editForm.require_password}
onChange={(e) => {
const checked = e.target.checked;
setEditForm(prev => ({
...prev,
require_password: checked,
new_password: checked ? prev.new_password : '',
confirm_new_password: checked ? prev.confirm_new_password : '',
}));
if (!checked) {
setShowNewPassword(false);
}
}}
/>
<div>
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
</p>
</div>
</label>
{!editForm.require_password && (
<div className="mt-2 rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
</div>
)}
</div>
{editForm.require_password && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.newPasswordLabel', 'New gallery password')}
</label>
<div className="relative">
<Input
type={showNewPassword ? 'text' : 'password'}
value={editForm.new_password}
onChange={(e) => setEditForm(prev => ({ ...prev, new_password: e.target.value }))}
placeholder={t('events.enterPassword')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showNewPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.confirmPassword')}
</label>
<Input
type={showNewPassword ? 'text' : 'password'}
value={editForm.confirm_new_password}
onChange={(e) => setEditForm(prev => ({ ...prev, confirm_new_password: e.target.value }))}
placeholder={t('events.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.sourceMode', 'Source Mode')}
@@ -848,7 +976,9 @@ export const EventDetailsPage: React.FC = () => {
</div>
<p className="text-sm text-neutral-600 mt-2">
{t('events.shareWithGuests')}
{isGalleryPublic(event.require_password)
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
: t('events.shareWithGuests')}
</p>
{!event.is_archived && (
@@ -22,6 +22,7 @@ import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../compone
import { BulkArchiveModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { isGalleryPublic } from '../../utils/accessControl';
import type { Event } from '../../types';
import { useTranslation } from 'react-i18next';
@@ -428,6 +429,17 @@ export const EventsListPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
<p className="text-xs text-neutral-500">{event.host_email}</p>
<div className="mt-1">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
isGalleryPublic(event.require_password)
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-700'
}`}
>
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
</span>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
+14 -3
View File
@@ -1,5 +1,16 @@
import { api } from '../config/api';
import type { LoginResponse, GalleryAuthResponse } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
...response,
event: response.event
? {
...response.event,
require_password: normalizeRequirePassword((response.event as any)?.require_password, true),
}
: response.event,
});
export const authService = {
// Admin authentication
@@ -24,7 +35,7 @@ export const authService = {
},
// Gallery authentication
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
async verifyGalleryPassword(slug: string, password?: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
slug,
password,
@@ -32,7 +43,7 @@ export const authService = {
});
// Token is now handled by GalleryAuthContext with slug-specific storage
return response.data;
return normalizeGalleryResponse(response.data);
},
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
@@ -40,7 +51,7 @@ export const authService = {
slug,
token,
});
return response.data;
return normalizeGalleryResponse(response.data);
},
async galleryLogout(slug?: string | null) {
+17 -3
View File
@@ -1,5 +1,11 @@
import { api } from '../config/api';
import type { Event } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
const normalizeEvent = (event: Event): Event => ({
...event,
require_password: normalizeRequirePassword((event as any)?.require_password, true),
});
interface CreateEventData {
event_type: string;
@@ -7,6 +13,7 @@ interface CreateEventData {
event_date: string;
host_email: string;
admin_email: string;
require_password?: boolean;
password: string;
welcome_message?: string;
color_theme?: string;
@@ -28,6 +35,7 @@ interface UpdateEventData {
event_date?: string;
host_email?: string;
admin_email?: string;
require_password?: boolean;
password?: string;
welcome_message?: string;
color_theme?: string;
@@ -64,19 +72,25 @@ export const eventsService = {
}
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
return response.data;
const data: any = response.data;
if (Array.isArray(data?.events)) {
data.events = data.events.map((event: Event) => normalizeEvent(event));
} else if (Array.isArray(data)) {
return data.map((event: Event) => normalizeEvent(event)) as any;
}
return data;
},
// Get single event details (admin)
async getEvent(id: number): Promise<Event> {
const response = await api.get<Event>(`/admin/events/${id}`);
return response.data;
return normalizeEvent(response.data as Event);
},
// Create new event (admin)
async createEvent(data: CreateEventData): Promise<Event> {
const response = await api.post<Event>('/admin/events', data);
return response.data;
return normalizeEvent(response.data as Event);
},
// Update event (admin)
+17 -2
View File
@@ -1,5 +1,6 @@
import { api } from '../config/api';
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
export const galleryService = {
// Verify share token
@@ -12,7 +13,11 @@ export const galleryService = {
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
const params = token ? { token } : {};
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
return response.data;
const data = response.data;
return {
...data,
requires_password: normalizeRequirePassword((data as any)?.requires_password, true),
};
},
// Get gallery photos (requires auth)
@@ -29,7 +34,17 @@ export const galleryService = {
}
}
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
return response.data;
const data = response.data;
const normalizedEvent = data?.event
? {
...data.event,
require_password: normalizeRequirePassword((data.event as any)?.require_password, true),
}
: data.event;
return {
...data,
event: normalizedEvent,
};
},
// Download single photo
+3
View File
@@ -17,6 +17,7 @@ export interface Event {
is_archived: boolean;
archive_path?: string;
archived_at?: string;
require_password?: boolean;
photo_count?: number;
total_size?: number;
recent_photos?: Array<{
@@ -92,6 +93,7 @@ export interface GalleryData {
disable_right_click?: boolean;
watermark_downloads?: boolean;
watermark_text?: string;
require_password?: boolean;
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
image_quality?: number;
use_canvas_rendering?: boolean;
@@ -134,6 +136,7 @@ export interface GalleryAuthResponse {
expires_at: string;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
require_password?: boolean;
};
}
+29
View File
@@ -0,0 +1,29 @@
export const normalizeRequirePassword = (value: unknown, defaultValue = true): boolean => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
return false;
}
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
return true;
}
}
return defaultValue;
};
export const isGalleryPublic = (value: unknown, defaultValue = true): boolean => {
return !normalizeRequirePassword(value, defaultValue);
};