Fix multiple production issues and add password change functionality

- Fixed frontend API URL configuration to use correct port 3002
- Fixed create event functionality by adding proper endpoint and fixing JSON parsing
- Fixed email settings save functionality by importing logActivity correctly
- Fixed admin settings save functionality by using api client instead of direct fetch
- Implemented password change functionality with modal and backend endpoint
- Added updated_at column to admin_users table
- Fixed all mock data issues - now using real backend data throughout

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-07 10:25:38 +02:00
parent f38a8ef598
commit 225d017718
16 changed files with 495 additions and 82 deletions
Binary file not shown.
View File
+4 -1
View File
@@ -14,6 +14,7 @@ const authRoutes = require('./src/routes/auth');
const eventRoutes = require('./src/routes/events');
const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin');
const adminAuthRoutes = require('./src/routes/adminAuth');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -27,7 +28,8 @@ const corsOptions = {
const allowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005',
'http://localhost:3002', // Vite dev server
'http://localhost:5173', // Vite dev server
'http://localhost:3002', // Backend server
'http://localhost:3001', // For API testing
'http://localhost:3000' // Direct backend access
];
@@ -75,6 +77,7 @@ app.use('/api/auth', authRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/gallery', galleryRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
+9
View File
@@ -92,8 +92,17 @@ async function initializeDatabase() {
table.string('password_hash').notNullable();
table.boolean('is_active').defaultTo(true);
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('updated_at').defaultTo(db.fn.now());
table.datetime('last_login');
});
} else {
// Check if updated_at column exists
const hasUpdatedAt = await db.schema.hasColumn('admin_users', 'updated_at');
if (!hasUpdatedAt) {
await db.schema.table('admin_users', (table) => {
table.datetime('updated_at').defaultTo(db.fn.now());
});
}
}
// Email configuration table
+56
View File
@@ -0,0 +1,56 @@
const express = require('express');
const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Change password
router.post('/change-password', [
adminAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'),
body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { currentPassword, newPassword } = req.body;
const userId = req.user.id;
// Get user from database
const user = await db('admin_users')
.where('id', userId)
.first();
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
if (!validPassword) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
// Hash new password
const newPasswordHash = await bcrypt.hash(newPassword, 10);
// Update password
await db('admin_users')
.where('id', userId)
.update({
password_hash: newPasswordHash,
updated_at: new Date()
});
res.json({ message: 'Password changed successfully' });
} catch (error) {
console.error('Password change error:', error);
res.status(500).json({ error: 'Failed to change password' });
}
});
module.exports = router;
+6 -8
View File
@@ -1,7 +1,7 @@
const express = require('express');
const nodemailer = require('nodemailer');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
@@ -83,13 +83,11 @@ router.post('/config', [
}
// Log activity
await db('activity_logs').insert({
activity_type: 'email_config_updated',
actor_type: 'admin',
actor_id: req.user.id,
actor_name: req.user.username,
metadata: JSON.stringify({ smtp_host, from_email })
});
await logActivity('email_config_updated',
{ smtp_host, from_email },
null,
{ type: 'admin', id: req.user.id, name: req.user.username }
);
res.json({ message: 'Email configuration updated successfully' });
} catch (error) {
+133 -33
View File
@@ -1,8 +1,120 @@
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
// Create new event
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('host_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(),
body('password').isLength({ min: 6 }),
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
body('welcome_message').optional().trim(),
body('color_theme').optional().trim()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
event_type,
event_name,
event_date,
host_email,
admin_email,
password,
welcome_message = '',
color_theme = 'default',
expiration_days = 30
} = req.body;
// Generate unique slug
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link
const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
// Hash password
const password_hash = await bcrypt.hash(password, 10);
// Calculate expiration date
const expires_at = new Date();
expires_at.setDate(expires_at.getDate() + expiration_days);
// Create folder structure
const eventPath = path.join(__dirname, '../../../storage/events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const [eventId] = await db('events').insert({
slug,
event_type,
event_name,
event_date,
host_email,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLink,
expires_at,
created_at: new Date(),
updated_at: new Date()
});
// Log activity
await logActivity('event_created',
{ event_type, expires_at },
eventId,
{ type: 'admin', id: req.user.id, name: req.user.username }
);
// Queue creation email
await db('email_queue').insert({
event_id: eventId,
recipient_email: host_email,
email_type: 'creation',
email_data: JSON.stringify({
event_name,
share_link: shareLink,
password,
expires_at: expires_at.toISOString()
}),
created_at: new Date()
});
res.json({
id: eventId,
slug,
event_name,
event_type,
share_link: shareLink,
expires_at
});
} catch (error) {
console.error('Error creating event:', error);
res.status(500).json({ error: 'Failed to create event' });
}
});
// Get all events with pagination and filters
router.get('/', adminAuth, async (req, res) => {
@@ -164,15 +276,11 @@ router.put('/:id', adminAuth, [
});
// Log activity
await db.logActivity({
type: 'event_updated',
actorType: 'admin',
actorId: req.user.id,
actorName: req.user.username,
eventId: id,
eventName: event.event_name,
metadata: { changes: Object.keys(updates) }
});
await logActivity('event_updated',
{ changes: Object.keys(updates), eventName: event.event_name },
id,
{ type: 'admin', id: req.user.id, name: req.user.username }
);
res.json({ message: 'Event updated successfully' });
} catch (error) {
@@ -199,13 +307,11 @@ router.delete('/:id', adminAuth, async (req, res) => {
await db('events').where('id', id).del();
// Log activity
await db.logActivity({
type: 'event_deleted',
actorType: 'admin',
actorId: req.user.id,
actorName: req.user.username,
metadata: { event_name: event.event_name }
});
await logActivity('event_deleted',
{ event_name: event.event_name },
null,
{ type: 'admin', id: req.user.id, name: req.user.username }
);
res.json({ message: 'Event deleted successfully' });
} catch (error) {
@@ -233,14 +339,11 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => {
});
// Log activity
await db.logActivity({
type: newStatus ? 'event_activated' : 'event_deactivated',
actorType: 'admin',
actorId: req.user.id,
actorName: req.user.username,
eventId: id,
eventName: event.event_name
});
await logActivity(newStatus ? 'event_activated' : 'event_deactivated',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.user.id, name: req.user.username }
);
res.json({
message: `Event ${newStatus ? 'activated' : 'deactivated'} successfully`,
@@ -276,14 +379,11 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
});
// Log activity
await db.logActivity({
type: 'event_archived',
actorType: 'admin',
actorId: req.user.id,
actorName: req.user.username,
eventId: id,
eventName: event.event_name
});
await logActivity('event_archived',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.user.id, name: req.user.username }
);
res.json({ message: 'Event archived successfully' });
} catch (error) {
+19 -1
View File
@@ -1,10 +1,11 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell } from 'lucide-react';
import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react';
import { format } from 'date-fns';
import { useAdminAuth } from '../../contexts';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { PasswordChangeModal } from './PasswordChangeModal';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -15,6 +16,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const { user, logout } = useAdminAuth();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
@@ -136,6 +138,16 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<Settings className="w-4 h-4" />
Settings
</button>
<button
onClick={() => {
setShowUserMenu(false);
setShowPasswordModal(true);
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
>
<Lock className="w-4 h-4" />
Change Password
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
@@ -149,6 +161,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</div>
</div>
</div>
{/* Password Change Modal */}
<PasswordChangeModal
isOpen={showPasswordModal}
onClose={() => setShowPasswordModal(false)}
/>
</header>
);
};
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { X, Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { Button, Input, Card } from '../common';
import { adminService } from '../../services/admin.service';
interface PasswordChangeModalProps {
isOpen: boolean;
onClose: () => void;
}
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const changePasswordMutation = useMutation({
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success('Password changed successfully');
onClose();
// Reset form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
},
onError: (error: any) => {
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error('Failed to change password');
}
}
});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.currentPassword) {
newErrors.currentPassword = 'Current password is required';
}
if (!formData.newPassword) {
newErrors.newPassword = 'New password is required';
} else if (formData.newPassword.length < 6) {
newErrors.newPassword = 'Password must be at least 6 characters';
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = 'Please confirm your new password';
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = 'New password must be different from current password';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
changePasswordMutation.mutate({
currentPassword: formData.currentPassword,
newPassword: formData.newPassword
});
};
const handleInputChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user types
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Change Password</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
Current Password
</label>
<div className="relative">
<Input
id="currentPassword"
type={showPasswords.current ? 'text' : 'password'}
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={errors.currentPassword}
placeholder="Enter current password"
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.current ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
New Password
</label>
<div className="relative">
<Input
id="newPassword"
type={showPasswords.new ? 'text' : 'password'}
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={errors.newPassword}
placeholder="Enter new password"
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.new ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
Confirm New Password
</label>
<div className="relative">
<Input
id="confirmPassword"
type={showPasswords.confirm ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder="Confirm new password"
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.confirm ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Password Requirements */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">Password Requirements:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>At least 6 characters long</li>
<li>Must be different from current password</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
isLoading={changePasswordMutation.isPending}
>
Change Password
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
+2 -1
View File
@@ -1,4 +1,5 @@
export { AdminLayout } from './AdminLayout';
export { AdminSidebar } from './AdminSidebar';
export { AdminHeader } from './AdminHeader';
export { ThemeCustomizer } from './ThemeCustomizer';
export { ThemeCustomizer } from './ThemeCustomizer';
export { PasswordChangeModal } from './PasswordChangeModal';
+1 -1
View File
@@ -7,7 +7,7 @@ export const GALLERY_TOKEN_KEY = 'gallery_token';
// Create axios instance
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001',
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3002',
headers: {
'Content-Type': 'application/json',
},
+2 -5
View File
@@ -32,7 +32,6 @@ const EVENT_TYPES = [
{ value: 'wedding', label: 'Wedding', emoji: '💒' },
{ value: 'birthday', label: 'Birthday', emoji: '🎂' },
{ value: 'corporate', label: 'Corporate', emoji: '🏢' },
{ value: 'party', label: 'Party', emoji: '🎉' },
{ value: 'other', label: 'Other', emoji: '📸' },
];
@@ -126,8 +125,6 @@ export const CreateEventPage: React.FC = () => {
return;
}
const expiresAt = addDays(new Date(), formData.expires_in_days);
createMutation.mutate({
event_type: formData.event_type,
event_name: formData.event_name,
@@ -135,9 +132,9 @@ export const CreateEventPage: React.FC = () => {
host_email: formData.host_email,
admin_email: formData.admin_email,
password: formData.password,
welcome_message: formData.welcome_message || undefined,
welcome_message: formData.welcome_message || '',
color_theme: formData.color_theme || undefined,
expires_at: expiresAt.toISOString(),
expiration_days: formData.expires_in_days,
});
};
@@ -78,8 +78,7 @@ export const EventDetailsPage: React.FC = () => {
// Extend expiration mutation
const extendMutation = useMutation({
mutationFn: (days: number) => {
const newDate = addDays(parseISO(event!.expires_at), days);
return eventsService.extendExpiration(parseInt(id!), newDate.toISOString());
return eventsService.extendExpiration(parseInt(id!), days);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
+12 -24
View File
@@ -84,18 +84,12 @@ export const SettingsPage: React.FC = () => {
// Save mutations
const saveGeneralMutation = useMutation({
mutationFn: async () => {
// Save each setting
const promises = Object.entries(generalSettings).map(([key, value]) =>
fetch('/api/admin/settings/general', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
},
body: JSON.stringify({ [`general_${key}`]: value })
})
);
await Promise.all(promises);
// Convert to the format expected by the API
const settingsData: Record<string, any> = {};
Object.entries(generalSettings).forEach(([key, value]) => {
settingsData[`general_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success('General settings saved successfully');
@@ -108,18 +102,12 @@ export const SettingsPage: React.FC = () => {
const saveSecurityMutation = useMutation({
mutationFn: async () => {
// Save each setting
const promises = Object.entries(securitySettings).map(([key, value]) =>
fetch('/api/admin/settings/security', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
},
body: JSON.stringify({ [`security_${key}`]: value })
})
);
await Promise.all(promises);
// Convert to the format expected by the API
const settingsData: Record<string, any> = {};
Object.entries(securitySettings).forEach(([key, value]) => {
settingsData[`security_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success('Security settings saved successfully');
+5
View File
@@ -91,5 +91,10 @@ export const adminService = {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
// Change password
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
await api.post('/api/admin/auth/change-password', data);
}
};
+11 -6
View File
@@ -10,10 +10,15 @@ interface CreateEventData {
password: string;
welcome_message?: string;
color_theme?: string;
expires_at: string;
expiration_days: number;
}
interface UpdateEventData {
event_name?: string;
event_date?: string;
host_email?: string;
admin_email?: string;
password?: string;
welcome_message?: string;
color_theme?: string;
expires_at?: string;
@@ -61,13 +66,13 @@ export const eventsService = {
// Update event (admin)
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
const response = await api.patch<Event>(`/api/admin/events/${id}`, data);
const response = await api.put<Event>(`/api/events/${id}`, data);
return response.data;
},
// Delete/deactivate event (admin)
async deleteEvent(id: number): Promise<void> {
await api.delete(`/api/admin/events/${id}`);
await api.delete(`/api/events/${id}`);
},
// Force archive event (admin)
@@ -76,9 +81,9 @@ export const eventsService = {
},
// Extend event expiration (admin)
async extendExpiration(id: number, newExpiryDate: string): Promise<Event> {
const response = await api.patch<Event>(`/api/admin/events/${id}`, {
expires_at: newExpiryDate,
async extendExpiration(id: number, days: number): Promise<Event> {
const response = await api.post<Event>(`/api/events/${id}/extend`, {
days,
});
return response.data;
},