Fix general settings route and req.user references

- Update frontend settings service to use correct /api/admin/settings/general route
- Fix all req.user to req.admin references in adminSettings.js
- Ensures settings can be saved without authentication errors
This commit is contained in:
2025-07-07 15:32:42 +02:00
parent f0768cd31b
commit 971397c338
20 changed files with 716 additions and 127 deletions
+19 -23
View File
@@ -5,7 +5,7 @@ import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { analyticsService } from './services/analytics.service';
import { GalleryAuthProvider, AdminAuthProvider } from './contexts';
import { GalleryAuthProvider } from './contexts';
import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import {
@@ -20,7 +20,7 @@ import {
BrandingPage,
SettingsPage
} from './pages/admin';
import { AdminLayout } from './components/admin';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink } from './components/common';
// Create a client
@@ -57,32 +57,28 @@ function App() {
<SkipLink />
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/:slug" element={
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
</GalleryAuthProvider>
} />
{/* Admin routes */}
<Route path="/admin/*" element={
<AdminAuthProvider>
<Routes>
<Route path="login" element={<AdminLoginPage />} />
<Route element={<AdminLayout />}>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="events" element={<EventsListPage />} />
<Route path="events/new" element={<CreateEventPage />} />
<Route path="events/:id" element={<EventDetailsPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="email" element={<EmailConfigPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="" element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Routes>
</AdminAuthProvider>
} />
{/* Admin routes - wrap with AdminAuthProvider */}
<Route path="/admin" element={<AdminAuthWrapper />}>
<Route path="login" element={<AdminLoginPage />} />
<Route element={<AdminLayout />}>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="events" element={<EventsListPage />} />
<Route path="events/new" element={<CreateEventPage />} />
<Route path="events/:id" element={<EventDetailsPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="email" element={<EmailConfigPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
@@ -0,0 +1,13 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { AdminAuthProvider } from '../../contexts';
export const AdminAuthWrapper: React.FC = () => {
return (
<AdminAuthProvider>
<Outlet />
</AdminAuthProvider>
);
};
AdminAuthWrapper.displayName = 'AdminAuthWrapper';
@@ -0,0 +1,200 @@
import React, { useState, useRef } from 'react';
import { Upload, X, Image, Loader2 } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
interface PhotoUploadProps {
eventId: number;
onUploadComplete?: () => void;
}
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [photoType, setPhotoType] = useState<'individual' | 'collage'>('individual');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
);
setSelectedFiles(prev => [...prev, ...imageFiles]);
};
const removeFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
if (selectedFiles.length === 0) return;
setIsUploading(true);
setUploadProgress(0);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('photos', file);
});
formData.append('type', photoType);
try {
const response = await fetch(`/api/admin/events/${eventId}/upload`, {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
console.log('Upload result:', result);
// Clear selected files
setSelectedFiles([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// Call callback
if (onUploadComplete) {
onUploadComplete();
}
} catch (error) {
console.error('Upload error:', error);
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
return (
<div className="space-y-4">
{/* Photo Type Selection */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Photo Type
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
value="individual"
checked={photoType === 'individual'}
onChange={(e) => setPhotoType(e.target.value as 'individual')}
className="mr-2"
/>
<span>Individual Photos</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="collage"
checked={photoType === 'collage'}
onChange={(e) => setPhotoType(e.target.value as 'collage')}
className="mr-2"
/>
<span>Collages</span>
</label>
</div>
</div>
{/* File Input Area */}
<div
className={clsx(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
"hover:border-primary-400 hover:bg-primary-50/50",
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30" : "border-neutral-300"
)}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-12 h-12 mx-auto text-neutral-400 mb-4" />
<p className="text-neutral-700 font-medium mb-1">
Click to upload or drag and drop
</p>
<p className="text-sm text-neutral-500">
JPEG, PNG or WebP (max 50MB per file)
</p>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
className="hidden"
/>
</div>
{/* Selected Files */}
{selectedFiles.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-700">
Selected files ({selectedFiles.length})
</p>
<div className="max-h-48 overflow-y-auto space-y-2">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Image className="w-5 h-5 text-neutral-400" />
<div>
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
{file.name}
</p>
<p className="text-xs text-neutral-500">
{formatFileSize(file.size)}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="p-1 hover:bg-neutral-200 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
)}
{/* Upload Button */}
<div className="flex justify-end">
<Button
variant="primary"
onClick={handleUpload}
disabled={selectedFiles.length === 0 || isUploading}
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
>
{isUploading ? 'Uploading...' : `Upload ${selectedFiles.length} Photo${selectedFiles.length !== 1 ? 's' : ''}`}
</Button>
</div>
{/* Progress Bar */}
{isUploading && uploadProgress > 0 && (
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</div>
);
};
PhotoUpload.displayName = 'PhotoUpload';
+3 -1
View File
@@ -2,4 +2,6 @@ export { AdminLayout } from './AdminLayout';
export { AdminSidebar } from './AdminSidebar';
export { AdminHeader } from './AdminHeader';
export { ThemeCustomizer } from './ThemeCustomizer';
export { PasswordChangeModal } from './PasswordChangeModal';
export { PasswordChangeModal } from './PasswordChangeModal';
export { AdminAuthWrapper } from './AdminAuthWrapper';
export { PhotoUpload } from './PhotoUpload';
+16 -2
View File
@@ -46,8 +46,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
// Check if user has a valid token on mount
const token = getAuthToken(false);
if (token) {
// TODO: Validate token with backend
setIsAuthenticated(true);
// Try to restore event data from localStorage
const storedEvent = localStorage.getItem('gallery_event');
if (storedEvent) {
try {
const eventData = JSON.parse(storedEvent);
setEvent(eventData);
setIsAuthenticated(true);
} catch (error) {
console.error('Failed to parse stored event data');
localStorage.removeItem('gallery_event');
}
}
}
setIsLoading(false);
}, []);
@@ -59,6 +69,9 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const response = await authService.verifyGalleryPassword(slug, password);
setEvent(response.event);
setIsAuthenticated(true);
// Store event data in localStorage
localStorage.setItem('gallery_event', JSON.stringify(response.event));
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid password');
throw err;
@@ -71,6 +84,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
authService.galleryLogout();
setIsAuthenticated(false);
setEvent(null);
localStorage.removeItem('gallery_event');
};
return (
+3 -3
View File
@@ -2,10 +2,10 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { galleryService } from '../services';
import { toast } from 'react-toastify';
export const useGalleryInfo = (slug: string) => {
export const useGalleryInfo = (slug: string, token?: string) => {
return useQuery({
queryKey: ['gallery-info', slug],
queryFn: () => galleryService.getGalleryInfo(slug),
queryKey: ['gallery-info', slug, token],
queryFn: () => galleryService.getGalleryInfo(slug, token),
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
});
+2 -2
View File
@@ -10,14 +10,14 @@ import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
export const GalleryPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
// Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!);
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
+69 -12
View File
@@ -13,12 +13,14 @@ import {
X,
AlertTriangle,
Copy,
CheckCircle
CheckCircle,
Upload
} from 'lucide-react';
import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
@@ -42,6 +44,7 @@ export const EventDetailsPage: React.FC = () => {
expires_at: '',
});
const [copiedLink, setCopiedLink] = useState(false);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
@@ -50,11 +53,12 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id,
});
// Fetch event statistics
// Fetch event statistics (skip if event doesn't exist or from admin context)
const { data: stats } = useQuery({
queryKey: ['admin-event-stats', event?.slug],
queryFn: () => galleryService.getGalleryStats(event!.slug),
enabled: !!event?.slug,
enabled: false, // Disable stats from admin panel as it requires gallery auth
retry: false,
});
// Update mutation
@@ -203,15 +207,17 @@ export const EventDetailsPage: React.FC = () => {
)}
</>
)}
<a
href={event.share_link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
>
<ExternalLink className="w-4 h-4" />
View Gallery
</a>
{event.share_link && (
<a
href={event.share_link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
>
<ExternalLink className="w-4 h-4" />
View Gallery
</a>
)}
</div>
</div>
</div>
@@ -357,6 +363,57 @@ export const EventDetailsPage: React.FC = () => {
</p>
</Card>
{/* Photo Management */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Photo Management</h2>
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(!showPhotoUpload)}
>
Upload Photos
</Button>
</div>
{showPhotoUpload && (
<div className="mb-4">
<PhotoUpload
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success('Photos uploaded successfully');
setShowPhotoUpload(false);
}}
/>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">Total Photos</span>
<span className="text-sm font-medium">{event.photo_count || 0}</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">Total Size</span>
<span className="text-sm font-medium">
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
</span>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Storage Location:</strong> /storage/events/active/{event.slug}/
</p>
<p className="text-xs text-blue-600 mt-1">
Photos can also be added by placing them in the 'individual' or 'collages' folders.
</p>
</div>
</div>
</Card>
{/* Actions */}
{!event.is_archived && (
<Card padding="md">
+16 -14
View File
@@ -346,17 +346,19 @@ export const EventsListPage: React.FC = () => {
<Edit className="w-4 h-4" />
View Details
</button>
<a
href={event.share_link}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => setActiveDropdown(null)}
>
<ExternalLink className="w-4 h-4" />
View Gallery
</a>
{!event.is_archived && (
{event.share_link ? (
<a
href={event.share_link}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => setActiveDropdown(null)}
>
<ExternalLink className="w-4 h-4" />
View Gallery
</a>
) : null}
{!event.is_archived ? (
<button
onClick={() => {
archiveMutation.mutate(event.id);
@@ -367,8 +369,8 @@ export const EventsListPage: React.FC = () => {
<Archive className="w-4 h-4" />
Archive Event
</button>
)}
{event.is_archived && (
) : null}
{event.is_archived ? (
<button
onClick={() => {
toast.info('Download archive coming soon');
@@ -379,7 +381,7 @@ export const EventsListPage: React.FC = () => {
<Download className="w-4 h-4" />
Download Archive
</button>
)}
) : null}
<button
onClick={() => {
if (confirm('Are you sure you want to delete this event?')) {
+9 -2
View File
@@ -2,9 +2,16 @@ import { api } from '../config/api';
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
export const galleryService = {
// Verify share token
async verifyToken(slug: string, token: string): Promise<{ valid: boolean }> {
const response = await api.get<{ valid: boolean }>(`/api/gallery/${slug}/verify-token/${token}`);
return response.data;
},
// Get basic gallery info (no auth required)
async getGalleryInfo(slug: string): Promise<GalleryInfo> {
const response = await api.get<GalleryInfo>(`/api/gallery/${slug}/info`);
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
const params = token ? { token } : {};
const response = await api.get<GalleryInfo>(`/api/gallery/${slug}/info`, { params });
return response.data;
},
+1 -1
View File
@@ -74,7 +74,7 @@ export const settingsService = {
// Update multiple settings at once
async updateSettings(settings: Record<string, any>): Promise<void> {
await api.put('/api/admin/settings', settings);
await api.put('/api/admin/settings/general', settings);
},
// Get storage information
+9
View File
@@ -16,6 +16,14 @@ export interface Event {
is_archived: boolean;
archive_path?: string;
archived_at?: string;
photo_count?: number;
total_size?: number;
recent_photos?: Array<{
filename: string;
type: string;
size_bytes: number;
uploaded_at: string;
}>;
}
export interface GalleryInfo {
@@ -25,6 +33,7 @@ export interface GalleryInfo {
expires_at: string;
is_active: boolean;
is_expired: boolean;
requires_password?: boolean;
}
export interface Photo {