Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a5e55ca96 | |||
| a72741c0d9 | |||
| e7ed7006fd | |||
| 3d3013d9d6 | |||
| bccaa649dc | |||
| 7aca927937 | |||
| e229c60b22 | |||
| 4966bc6a58 | |||
| 617f292516 | |||
| 8e95004022 | |||
| 93df328853 | |||
| 657e74a2e3 |
@@ -39,6 +39,12 @@ docker-compose -f docker-compose.prod.yml up -d # Production deployment
|
|||||||
pm2 start ecosystem.config.js # Alternative: PM2 deployment
|
pm2 start ecosystem.config.js # Alternative: PM2 deployment
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**⚠️ CRITICAL PRODUCTION NOTICE:**
|
||||||
|
- Production runs on a SEPARATE SERVER - never assume local changes affect production
|
||||||
|
- ALWAYS request production server details before any troubleshooting
|
||||||
|
- NO trial-and-error approaches in production - data loss is unacceptable
|
||||||
|
- Every change must be thoroughly analyzed and tested locally first
|
||||||
|
|
||||||
## Key Product Requirements (from PRD)
|
## Key Product Requirements (from PRD)
|
||||||
|
|
||||||
### Core Features
|
### Core Features
|
||||||
@@ -127,6 +133,41 @@ Background services run as separate processes:
|
|||||||
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
||||||
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
||||||
|
|
||||||
|
## Troubleshooting Guidelines
|
||||||
|
|
||||||
|
### Before ANY Production Troubleshooting:
|
||||||
|
1. **ALWAYS request specific details**:
|
||||||
|
- Production server URL/IP
|
||||||
|
- Current error messages/logs
|
||||||
|
- Recent changes or deployments
|
||||||
|
- Affected users/galleries
|
||||||
|
- Time of issue occurrence
|
||||||
|
|
||||||
|
2. **Thorough Analysis Required**:
|
||||||
|
- Use detailed thinking/analysis for EVERY troubleshooting task
|
||||||
|
- Review all related code before suggesting changes
|
||||||
|
- Consider all potential side effects
|
||||||
|
- Never make assumptions about production environment
|
||||||
|
|
||||||
|
3. **Safe Troubleshooting Steps**:
|
||||||
|
- First, reproduce issue in local/dev environment
|
||||||
|
- Analyze logs without modifying production
|
||||||
|
- Create detailed action plan before any changes
|
||||||
|
- Always have rollback strategy ready
|
||||||
|
- Document every step taken
|
||||||
|
|
||||||
|
### Common Issues & Safe Approaches:
|
||||||
|
- **Email not sending**: Check email_queue table, SMTP settings, service status
|
||||||
|
- **Photos not loading**: Verify file permissions, storage paths, nginx config
|
||||||
|
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
|
||||||
|
- **Performance problems**: Analyze with monitoring tools first, never experiment
|
||||||
|
|
||||||
|
### Data Safety Rules:
|
||||||
|
- NEVER delete or modify production data without explicit backup confirmation
|
||||||
|
- ALWAYS verify backups exist before any data operations
|
||||||
|
- NO direct database modifications without transaction safety
|
||||||
|
- Log all actions for audit trail
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
### Backend (.env)
|
### Backend (.env)
|
||||||
@@ -259,3 +300,29 @@ const { theme, setTheme, setThemeByName } = useTheme();
|
|||||||
- System uptime: 99.9%
|
- System uptime: 99.9%
|
||||||
- Email delivery rate: >98%
|
- Email delivery rate: >98%
|
||||||
- Successful archiving: 100%
|
- Successful archiving: 100%
|
||||||
|
|
||||||
|
## Documentation & Development Practices
|
||||||
|
|
||||||
|
### Documentation Guidelines:
|
||||||
|
- **NEVER create new documentation files for simple tasks**
|
||||||
|
- **ALWAYS update existing documentation (like this CLAUDE.md)**
|
||||||
|
- Only create new .md files when explicitly requested
|
||||||
|
- Avoid creating temporary scripts for one-off tasks
|
||||||
|
|
||||||
|
### Development Best Practices:
|
||||||
|
- Test all changes thoroughly in local environment first
|
||||||
|
- Use version control for all changes
|
||||||
|
- Keep commits atomic and well-described
|
||||||
|
- Review impact on all integrated services
|
||||||
|
- Consider backward compatibility
|
||||||
|
- Update tests when changing functionality
|
||||||
|
|
||||||
|
### Production Deployment Checklist:
|
||||||
|
- [ ] All tests passing locally
|
||||||
|
- [ ] Linting and type checks pass
|
||||||
|
- [ ] Database migrations tested with rollback plan
|
||||||
|
- [ ] Environment variables documented
|
||||||
|
- [ ] Backup strategy confirmed
|
||||||
|
- [ ] Monitoring alerts configured
|
||||||
|
- [ ] Rollback procedure documented
|
||||||
|
- [ ] Stakeholders notified of maintenance window
|
||||||
+4
-4
@@ -40,10 +40,10 @@ const config = {
|
|||||||
keepAliveInitialDelayMillis: 0
|
keepAliveInitialDelayMillis: 0
|
||||||
},
|
},
|
||||||
pool: {
|
pool: {
|
||||||
min: 2,
|
min: 5,
|
||||||
max: 10,
|
max: 25,
|
||||||
acquireTimeoutMillis: 30000,
|
acquireTimeoutMillis: 60000,
|
||||||
createTimeoutMillis: 30000,
|
createTimeoutMillis: 60000,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
reapIntervalMillis: 1000,
|
reapIntervalMillis: 1000,
|
||||||
createRetryIntervalMillis: 200,
|
createRetryIntervalMillis: 200,
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.65",
|
"version": "1.0.70",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.65",
|
"version": "1.0.70",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.65",
|
"version": "1.0.70",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const { initializeTransporter, startEmailQueueProcessor } = require('./src/servi
|
|||||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||||
const logger = require('./src/utils/logger');
|
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
const authRoutes = require('./src/routes/auth-enhanced');
|
const authRoutes = require('./src/routes/auth-enhanced');
|
||||||
|
|||||||
@@ -4,6 +4,33 @@ const knexConfig = require('../../knexfile');
|
|||||||
// Create database connection with built-in retry logic
|
// Create database connection with built-in retry logic
|
||||||
const db = knex(knexConfig);
|
const db = knex(knexConfig);
|
||||||
|
|
||||||
|
// Connection retry configuration
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const RETRY_DELAY = 1000;
|
||||||
|
|
||||||
|
// Wrapper function to handle connection retries
|
||||||
|
async function withRetry(queryFn, retries = MAX_RETRIES) {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
return await queryFn();
|
||||||
|
} catch (error) {
|
||||||
|
const isConnectionError = error.message && (
|
||||||
|
error.message.includes('Connection terminated unexpectedly') ||
|
||||||
|
error.message.includes('Connection ended unexpectedly') ||
|
||||||
|
error.message.includes('ECONNREFUSED') ||
|
||||||
|
error.message.includes('ETIMEDOUT')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isConnectionError && i < retries - 1) {
|
||||||
|
console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initializeDatabase() {
|
async function initializeDatabase() {
|
||||||
// Events table
|
// Events table
|
||||||
const hasEventsTable = await db.schema.hasTable('events');
|
const hasEventsTable = await db.schema.hasTable('events');
|
||||||
@@ -225,4 +252,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { db, initializeDatabase, logActivity };
|
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
// Middleware to verify gallery access
|
// Middleware to verify gallery access
|
||||||
@@ -11,13 +11,15 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
const event = await db('events')
|
const event = await withRetry(async () => {
|
||||||
.where({
|
return await db('events')
|
||||||
id: decoded.eventId,
|
.where({
|
||||||
is_active: formatBoolean(true),
|
id: decoded.eventId,
|
||||||
is_archived: formatBoolean(false)
|
is_active: formatBoolean(true),
|
||||||
})
|
is_archived: formatBoolean(false)
|
||||||
.first();
|
})
|
||||||
|
.first();
|
||||||
|
});
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000;
|
|||||||
// Cache for session timeout setting
|
// Cache for session timeout setting
|
||||||
let cachedTimeout = null;
|
let cachedTimeout = null;
|
||||||
let cacheExpiry = 0;
|
let cacheExpiry = 0;
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
|
||||||
|
|
||||||
// Clean up expired sessions every 5 minutes
|
// Clean up expired sessions every 5 minutes
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ router.get('/stats', adminAuth, async (req, res) => {
|
|||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
// Get total downloads (last 30 days)
|
// Get total downloads (last 30 days) - include both single and bulk downloads
|
||||||
const totalDownloads = await db('access_logs')
|
const totalDownloads = await db('access_logs')
|
||||||
.where('action', 'download')
|
.whereIn('action', ['download', 'download_all'])
|
||||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
@@ -73,7 +73,7 @@ router.get('/stats', adminAuth, async (req, res) => {
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
const previousDownloads = await db('access_logs')
|
const previousDownloads = await db('access_logs')
|
||||||
.where('action', 'download')
|
.whereIn('action', ['download', 'download_all'])
|
||||||
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
||||||
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
||||||
.count('id as count')
|
.count('id as count')
|
||||||
@@ -243,10 +243,10 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
|||||||
.where('timestamp', '>=', startDateStr)
|
.where('timestamp', '>=', startDateStr)
|
||||||
.groupByRaw('DATE(timestamp)');
|
.groupByRaw('DATE(timestamp)');
|
||||||
|
|
||||||
// Get downloads per day
|
// Get downloads per day - include both single and bulk downloads
|
||||||
const downloadsData = await db('access_logs')
|
const downloadsData = await db('access_logs')
|
||||||
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
||||||
.where('action', 'download')
|
.whereIn('action', ['download', 'download_all'])
|
||||||
.where('timestamp', '>=', startDateStr)
|
.where('timestamp', '>=', startDateStr)
|
||||||
.groupByRaw('DATE(timestamp)');
|
.groupByRaw('DATE(timestamp)');
|
||||||
|
|
||||||
@@ -272,14 +272,15 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
|||||||
if (dateObj) dateObj.uniqueVisitors = row.count;
|
if (dateObj) dateObj.uniqueVisitors = row.count;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get top galleries by views
|
// Get top galleries by views with additional metrics
|
||||||
const topGalleries = await db('access_logs')
|
const topGalleries = await db('access_logs')
|
||||||
.select('events.event_name', 'events.slug')
|
.select('events.id', 'events.event_name', 'events.slug')
|
||||||
.select(db.raw('COUNT(*) as views'))
|
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
|
||||||
|
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
|
||||||
|
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
|
||||||
.join('events', 'access_logs.event_id', 'events.id')
|
.join('events', 'access_logs.event_id', 'events.id')
|
||||||
.where('access_logs.action', 'view')
|
|
||||||
.where('access_logs.timestamp', '>=', startDateStr)
|
.where('access_logs.timestamp', '>=', startDateStr)
|
||||||
.groupBy('events.id')
|
.groupBy('events.id', 'events.event_name', 'events.slug')
|
||||||
.orderBy('views', 'desc')
|
.orderBy('views', 'desc')
|
||||||
.limit(5);
|
.limit(5);
|
||||||
|
|
||||||
@@ -309,10 +310,33 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
|||||||
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
|
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate totals for the period (matching /stats logic)
|
||||||
|
const totalViews = await db('access_logs')
|
||||||
|
.where('action', 'view')
|
||||||
|
.where('timestamp', '>=', startDateStr)
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const totalDownloadsCount = await db('access_logs')
|
||||||
|
.whereIn('action', ['download', 'download_all'])
|
||||||
|
.where('timestamp', '>=', startDateStr)
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const totalUniqueVisitors = await db('access_logs')
|
||||||
|
.where('timestamp', '>=', startDateStr)
|
||||||
|
.countDistinct('ip_address as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
chartData: dates,
|
chartData: dates,
|
||||||
topGalleries,
|
topGalleries,
|
||||||
devices
|
devices,
|
||||||
|
totals: {
|
||||||
|
views: totalViews?.count || 0,
|
||||||
|
downloads: totalDownloadsCount?.count || 0,
|
||||||
|
uniqueVisitors: totalUniqueVisitors?.count || 0
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Analytics error:', error);
|
console.error('Analytics error:', error);
|
||||||
|
|||||||
@@ -496,6 +496,43 @@ router.put('/security', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update analytics settings
|
||||||
|
router.put('/analytics', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = req.body;
|
||||||
|
|
||||||
|
// Update or insert each setting
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
await db('app_settings')
|
||||||
|
.insert({
|
||||||
|
setting_key: key,
|
||||||
|
setting_value: JSON.stringify(value),
|
||||||
|
setting_type: 'analytics',
|
||||||
|
updated_at: new Date()
|
||||||
|
})
|
||||||
|
.onConflict('setting_key')
|
||||||
|
.merge({
|
||||||
|
setting_value: JSON.stringify(value),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await db('activity_logs').insert({
|
||||||
|
activity_type: 'analytics_settings_updated',
|
||||||
|
actor_type: 'admin',
|
||||||
|
actor_id: req.admin.id,
|
||||||
|
actor_name: req.admin.username,
|
||||||
|
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Analytics settings updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Analytics settings update error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update analytics settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get storage info
|
// Get storage info
|
||||||
router.get('/storage/info', adminAuth, async (req, res) => {
|
router.get('/storage/info', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get public settings (branding and theme)
|
// Get public settings (branding and theme)
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Fetch branding, theme, general, and select security settings
|
// Fetch branding, theme, general, and security settings
|
||||||
const settings = await db('app_settings')
|
// Note: We include analytics in the query but it might not exist yet
|
||||||
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
|
const settings = await withRetry(async () => {
|
||||||
.select('setting_key', 'setting_value');
|
return await db('app_settings')
|
||||||
|
.where(function() {
|
||||||
|
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
|
||||||
|
.orWhere('setting_key', 'like', 'analytics_%');
|
||||||
|
})
|
||||||
|
.select('setting_key', 'setting_value');
|
||||||
|
});
|
||||||
|
|
||||||
// Convert to object format
|
// Convert to object format
|
||||||
const settingsObject = {};
|
const settingsObject = {};
|
||||||
@@ -41,7 +47,12 @@ router.get('/', async (req, res) => {
|
|||||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||||
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
||||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true'
|
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
||||||
|
// Umami analytics configuration (only if enabled)
|
||||||
|
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
|
||||||
|
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
|
||||||
|
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
|
||||||
|
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(publicSettings);
|
res.json(publicSettings);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async function processEmailQueue() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start email queue processor
|
// Start email queue processor
|
||||||
setInterval(processEmailQueue, 60000); // Process every minute
|
// DISABLED: Using emailProcessor.js instead to prevent duplicate connections
|
||||||
|
// setInterval(processEmailQueue, 60000); // Process every minute
|
||||||
|
|
||||||
module.exports = { sendEmail, processEmailQueue };
|
module.exports = { sendEmail, processEmailQueue };
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ async function createAuthRateLimiter() {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
headers: {
|
headers: {
|
||||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||||
'x-real-ip': req.headers['x-real-ip'
|
'x-real-ip': req.headers['x-real-ip']
|
||||||
},
|
},
|
||||||
requestUrl: req.originalUrl,
|
requestUrl: req.originalUrl,
|
||||||
authType: req.path.includes('admin') ? 'admin' : 'gallery',
|
authType: req.path.includes('admin') ? 'admin' : 'gallery',
|
||||||
|
|||||||
@@ -113,6 +113,84 @@ function validatePassword(password, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get complexity settings from database
|
||||||
|
* @returns {Object} - Password complexity configuration
|
||||||
|
*/
|
||||||
|
async function getPasswordComplexitySettings() {
|
||||||
|
try {
|
||||||
|
const { db, withRetry } = require('../database/db');
|
||||||
|
|
||||||
|
// Use retry wrapper to handle connection failures
|
||||||
|
const settings = await withRetry(async () => {
|
||||||
|
return await db('app_settings')
|
||||||
|
.where('setting_key', 'security_password_complexity_level')
|
||||||
|
.first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings || !settings.setting_value) {
|
||||||
|
return 'moderate'; // Default
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = typeof settings.setting_value === 'string'
|
||||||
|
? JSON.parse(settings.setting_value)
|
||||||
|
: settings.setting_value;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get password complexity settings:', error);
|
||||||
|
return 'moderate'; // Default on error - ensures app continues working
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get password configuration based on complexity level
|
||||||
|
* @param {string} complexityLevel - Complexity level (simple, moderate, strong, very_strong)
|
||||||
|
* @returns {Object} - Password configuration
|
||||||
|
*/
|
||||||
|
function getPasswordConfigForComplexity(complexityLevel) {
|
||||||
|
const configs = {
|
||||||
|
simple: {
|
||||||
|
minLength: 6,
|
||||||
|
requireUppercase: false,
|
||||||
|
requireLowercase: false,
|
||||||
|
requireNumbers: false,
|
||||||
|
requireSpecialChars: false,
|
||||||
|
preventCommonPasswords: true,
|
||||||
|
minStrengthScore: 0
|
||||||
|
},
|
||||||
|
moderate: {
|
||||||
|
minLength: 8,
|
||||||
|
requireUppercase: true,
|
||||||
|
requireLowercase: true,
|
||||||
|
requireNumbers: true,
|
||||||
|
requireSpecialChars: false,
|
||||||
|
preventCommonPasswords: true,
|
||||||
|
minStrengthScore: 2
|
||||||
|
},
|
||||||
|
strong: {
|
||||||
|
minLength: 12,
|
||||||
|
requireUppercase: true,
|
||||||
|
requireLowercase: true,
|
||||||
|
requireNumbers: true,
|
||||||
|
requireSpecialChars: false,
|
||||||
|
preventCommonPasswords: true,
|
||||||
|
minStrengthScore: 3
|
||||||
|
},
|
||||||
|
very_strong: {
|
||||||
|
minLength: 12,
|
||||||
|
requireUppercase: true,
|
||||||
|
requireLowercase: true,
|
||||||
|
requireNumbers: true,
|
||||||
|
requireSpecialChars: true,
|
||||||
|
preventCommonPasswords: true,
|
||||||
|
minStrengthScore: 3
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return configs[complexityLevel] || configs.moderate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate password for specific contexts (admin, gallery)
|
* Validate password for specific contexts (admin, gallery)
|
||||||
* @param {string} password - Password to validate
|
* @param {string} password - Password to validate
|
||||||
@@ -120,19 +198,16 @@ function validatePassword(password, options = {}) {
|
|||||||
* @param {Object} userData - Additional user data for context-aware validation
|
* @param {Object} userData - Additional user data for context-aware validation
|
||||||
* @returns {Object} - Validation result
|
* @returns {Object} - Validation result
|
||||||
*/
|
*/
|
||||||
function validatePasswordInContext(password, context, userData = {}) {
|
async function validatePasswordInContext(password, context, userData = {}) {
|
||||||
// For gallery context, use more lenient validation
|
// For gallery context, use dynamic complexity settings
|
||||||
if (context === 'gallery') {
|
if (context === 'gallery') {
|
||||||
// Gallery-specific validation options
|
// Get complexity settings from database
|
||||||
|
const complexityLevel = await getPasswordComplexitySettings();
|
||||||
|
|
||||||
|
// Get configuration for the complexity level
|
||||||
const galleryOptions = {
|
const galleryOptions = {
|
||||||
minLength: 6, // Reduced minimum length
|
...getPasswordConfigForComplexity(complexityLevel),
|
||||||
requireUppercase: false, // Don't require uppercase for galleries
|
skipStrengthCheck: complexityLevel === 'simple' // Skip zxcvbn for simple passwords
|
||||||
requireLowercase: false, // Don't require lowercase for galleries
|
|
||||||
requireNumbers: false, // Numbers are optional
|
|
||||||
requireSpecialChars: false, // Special chars are optional
|
|
||||||
preventCommonPasswords: true, // Still prevent common passwords
|
|
||||||
minStrengthScore: 0, // Accept any score for galleries
|
|
||||||
skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Base validation with gallery-specific options
|
// Base validation with gallery-specific options
|
||||||
@@ -281,5 +356,7 @@ module.exports = {
|
|||||||
generateSecurePassword,
|
generateSecurePassword,
|
||||||
getBcryptRounds,
|
getBcryptRounds,
|
||||||
logPasswordValidationFailure,
|
logPasswordValidationFailure,
|
||||||
|
getPasswordComplexitySettings,
|
||||||
|
getPasswordConfigForComplexity,
|
||||||
PASSWORD_CONFIG
|
PASSWORD_CONFIG
|
||||||
};
|
};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.65",
|
"version": "1.0.69",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.65",
|
"version": "1.0.69",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.65",
|
"version": "1.0.69",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+26
-12
@@ -44,17 +44,26 @@ function App() {
|
|||||||
// Initialize Umami Analytics based on settings
|
// Initialize Umami Analytics based on settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeAnalytics = async () => {
|
const initializeAnalytics = async () => {
|
||||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
try {
|
||||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
// Fetch public settings to get Umami configuration
|
||||||
|
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||||
|
const settings = await response.json();
|
||||||
|
|
||||||
if (umamiUrl && umamiWebsiteId) {
|
// Check if Umami is enabled and configured in backend settings
|
||||||
try {
|
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
|
||||||
// Fetch public settings to check if analytics is enabled
|
// Use backend configuration
|
||||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
analyticsService.initialize({
|
||||||
const settings = await response.json();
|
websiteId: settings.umami_website_id,
|
||||||
|
hostUrl: settings.umami_url,
|
||||||
|
autoTrack: true,
|
||||||
|
doNotTrack: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fall back to environment variables if backend not configured
|
||||||
|
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||||
|
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||||
|
|
||||||
// Only initialize if analytics is enabled in settings
|
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
|
||||||
if (settings.enable_analytics !== false) {
|
|
||||||
analyticsService.initialize({
|
analyticsService.initialize({
|
||||||
websiteId: umamiWebsiteId,
|
websiteId: umamiWebsiteId,
|
||||||
hostUrl: umamiUrl,
|
hostUrl: umamiUrl,
|
||||||
@@ -62,9 +71,14 @@ function App() {
|
|||||||
doNotTrack: true
|
doNotTrack: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to fetch settings for analytics:', error);
|
} catch (error) {
|
||||||
// Initialize analytics anyway if settings fetch fails
|
console.error('Failed to fetch settings for analytics:', error);
|
||||||
|
// Fall back to environment variables on error
|
||||||
|
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||||
|
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||||
|
|
||||||
|
if (umamiUrl && umamiWebsiteId) {
|
||||||
analyticsService.initialize({
|
analyticsService.initialize({
|
||||||
websiteId: umamiWebsiteId,
|
websiteId: umamiWebsiteId,
|
||||||
hostUrl: umamiUrl,
|
hostUrl: umamiUrl,
|
||||||
|
|||||||
@@ -2,9 +2,28 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Globe } from 'lucide-react';
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
// SVG Flag Components
|
||||||
|
const GBFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||||
|
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#012169" d="M0 0h640v480H0z"/>
|
||||||
|
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0h75z"/>
|
||||||
|
<path fill="#C8102E" d="m424 281 216 159v40L369 281h55zm-184 20 6 35L54 480H0l240-179zM640 0v3L391 191l2-44L590 0h50zM0 0l239 176h-60L0 42V0z"/>
|
||||||
|
<path fill="#FFF" d="M241 0v480h160V0H241zM0 160v160h640V160H0z"/>
|
||||||
|
<path fill="#C8102E" d="M0 193v96h640v-96H0zM273 0v480h96V0h-96z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||||
|
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#000" d="M0 0h640v160H0z"/>
|
||||||
|
<path fill="#D00" d="M0 160h640v160H0z"/>
|
||||||
|
<path fill="#FFCE00" d="M0 320h640v160H0z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
{ code: 'en', name: 'English', Flag: GBFlag },
|
||||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LanguageSelector: React.FC = () => {
|
export const LanguageSelector: React.FC = () => {
|
||||||
@@ -25,7 +44,7 @@ export const LanguageSelector: React.FC = () => {
|
|||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
>
|
>
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
<span>{currentLanguage.flag}</span>
|
<currentLanguage.Flag className="w-5 h-5" />
|
||||||
<span>{currentLanguage.name}</span>
|
<span>{currentLanguage.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -41,7 +60,7 @@ export const LanguageSelector: React.FC = () => {
|
|||||||
: 'text-neutral-700'
|
: 'text-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg">{language.flag}</span>
|
<language.Flag className="w-5 h-5" />
|
||||||
<span>{language.name}</span>
|
<span>{language.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||||
import { de, enUS } from 'date-fns/locale';
|
import { de, enUS } from 'date-fns/locale';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { settingsService } from '../services/settings.service';
|
||||||
|
|
||||||
export const useLocalizedDate = () => {
|
export const useLocalizedDate = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
// Fetch admin settings to get the date format
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ['admin-settings-general'],
|
||||||
|
queryFn: () => settingsService.getSettingsByType('general'),
|
||||||
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
const getLocale = () => {
|
const getLocale = () => {
|
||||||
return i18n.language === 'de' ? de : enUS;
|
return i18n.language === 'de' ? de : enUS;
|
||||||
};
|
};
|
||||||
|
|
||||||
const format = (date: Date | string, formatStr: string) => {
|
const format = (date: Date | string, formatStr?: string) => {
|
||||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||||
return dateFnsFormat(dateObj, formatStr, { locale: getLocale() });
|
// Use admin-configured date format if available and no format string provided
|
||||||
|
const dateFormat = formatStr || settings?.general_date_format || 'PPP';
|
||||||
|
return dateFnsFormat(dateObj, dateFormat, { locale: getLocale() });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
|
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
|
||||||
@@ -22,6 +33,7 @@ export const useLocalizedDate = () => {
|
|||||||
return {
|
return {
|
||||||
format,
|
format,
|
||||||
formatDistanceToNow,
|
formatDistanceToNow,
|
||||||
locale: getLocale()
|
locale: getLocale(),
|
||||||
|
dateFormat: settings?.general_date_format || 'PPP'
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -428,6 +428,12 @@
|
|||||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
"requirePassword": "Passwort für alle Galerien erforderlich",
|
||||||
"minPasswordLength": "Minimale Passwortlänge",
|
"minPasswordLength": "Minimale Passwortlänge",
|
||||||
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
||||||
|
"passwordComplexity": "Passwort-Komplexität",
|
||||||
|
"passwordComplexityHelp": "Sicherheitsstufe für Galerie-Passwörter",
|
||||||
|
"complexitySimple": "Einfach (6+ Zeichen, beliebiger Text)",
|
||||||
|
"complexityModerate": "Moderat (8+ Zeichen, Groß-/Kleinschreibung/Zahlen)",
|
||||||
|
"complexityStrong": "Stark (12+ Zeichen, Groß-/Kleinschreibung/Zahlen)",
|
||||||
|
"complexityVeryStrong": "Sehr stark (12+ Zeichen, alle Zeichentypen)",
|
||||||
"sessionAuth": "Sitzung & Authentifizierung",
|
"sessionAuth": "Sitzung & Authentifizierung",
|
||||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||||
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
||||||
@@ -721,6 +727,12 @@
|
|||||||
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
||||||
"general_settings_updated": "Allgemeine Einstellungen aktualisiert",
|
"general_settings_updated": "Allgemeine Einstellungen aktualisiert",
|
||||||
"favicon_uploaded": "Favicon hochgeladen",
|
"favicon_uploaded": "Favicon hochgeladen",
|
||||||
|
"analytics_settings_updated": "Analytik-Einstellungen aktualisiert",
|
||||||
|
"cms_page_updated": "CMS-Seite aktualisiert: {{page}}",
|
||||||
|
"security_settings_updated": "Sicherheitseinstellungen aktualisiert",
|
||||||
|
"password_reset": "Passwort zurückgesetzt für: {{eventName}}",
|
||||||
|
"admin_logout": "Admin {{actorName}} abgemeldet",
|
||||||
|
"system_activity": "Systemaktivität: {{type}}",
|
||||||
"unknown": "Unbekannte Aktivität"
|
"unknown": "Unbekannte Aktivität"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -447,6 +447,12 @@
|
|||||||
"requirePassword": "Require password for all galleries",
|
"requirePassword": "Require password for all galleries",
|
||||||
"minPasswordLength": "Minimum Password Length",
|
"minPasswordLength": "Minimum Password Length",
|
||||||
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
||||||
|
"passwordComplexity": "Password Complexity",
|
||||||
|
"passwordComplexityHelp": "Security level required for gallery passwords",
|
||||||
|
"complexitySimple": "Simple (6+ chars, any text)",
|
||||||
|
"complexityModerate": "Moderate (8+ chars, mixed case/numbers)",
|
||||||
|
"complexityStrong": "Strong (12+ chars, uppercase/lowercase/numbers)",
|
||||||
|
"complexityVeryStrong": "Very Strong (12+ chars, all character types)",
|
||||||
"sessionAuth": "Session & Authentication",
|
"sessionAuth": "Session & Authentication",
|
||||||
"sessionTimeout": "Session Timeout (minutes)",
|
"sessionTimeout": "Session Timeout (minutes)",
|
||||||
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
||||||
@@ -494,6 +500,30 @@
|
|||||||
"sent": "Sent",
|
"sent": "Sent",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"lastUpdate": "Last update"
|
"lastUpdate": "Last update"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Analytics",
|
||||||
|
"umamiIntegration": "Umami Analytics Integration",
|
||||||
|
"enableUmami": "Enable Umami Analytics",
|
||||||
|
"umamiUrl": "Umami URL",
|
||||||
|
"umamiUrlHelp": "The URL of your Umami instance (e.g., https://analytics.yourdomain.com)",
|
||||||
|
"websiteId": "Website ID",
|
||||||
|
"websiteIdHelp": "Your Umami website ID (found in Umami dashboard)",
|
||||||
|
"shareUrl": "Share URL (Optional)",
|
||||||
|
"shareUrlHelp": "Public share URL for embedding the full dashboard (create in Umami)",
|
||||||
|
"umamiInfo": "About Umami Analytics",
|
||||||
|
"umamiInfoText": "Umami is a privacy-focused, open-source analytics platform. It tracks page views, unique visitors, and custom events without using cookies.",
|
||||||
|
"learnMore": "Learn more about Umami",
|
||||||
|
"saveAnalyticsSettings": "Save Analytics Settings",
|
||||||
|
"backendAnalytics": "Backend Analytics",
|
||||||
|
"backendAnalyticsText": "The system also tracks basic analytics server-side for security and performance monitoring.",
|
||||||
|
"tracked": "Tracked Metrics",
|
||||||
|
"galleryViews": "Gallery page views",
|
||||||
|
"photoDownloads": "Individual and bulk downloads",
|
||||||
|
"uniqueVisitors": "Unique visitors by IP",
|
||||||
|
"deviceTypes": "Device types from user agents",
|
||||||
|
"privacy": "Privacy",
|
||||||
|
"privacyText": "IP addresses are hashed for privacy. No personal data is stored. Analytics data is retained for 90 days."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
@@ -772,6 +802,12 @@
|
|||||||
"category_deleted": "Category deleted: {{categoryName}}",
|
"category_deleted": "Category deleted: {{categoryName}}",
|
||||||
"general_settings_updated": "General settings updated",
|
"general_settings_updated": "General settings updated",
|
||||||
"favicon_uploaded": "Favicon uploaded",
|
"favicon_uploaded": "Favicon uploaded",
|
||||||
|
"analytics_settings_updated": "Analytics settings updated",
|
||||||
|
"cms_page_updated": "CMS page updated: {{page}}",
|
||||||
|
"security_settings_updated": "Security settings updated",
|
||||||
|
"password_reset": "Password reset for: {{eventName}}",
|
||||||
|
"admin_logout": "Admin {{actorName}} logged out",
|
||||||
|
"system_activity": "System activity: {{type}}",
|
||||||
"unknown": "Unknown activity"
|
"unknown": "Unknown activity"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { Calendar, AlertCircle, Clock } from 'lucide-react';
|
import { AlertCircle, Clock } from 'lucide-react';
|
||||||
import { differenceInDays, parseISO } from 'date-fns';
|
import { differenceInDays, parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
||||||
@@ -292,10 +292,15 @@ export const GalleryPage: React.FC = () => {
|
|||||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
|
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
|
||||||
{galleryInfo?.event_name}
|
{galleryInfo?.event_name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
{galleryInfo?.event_type && (
|
||||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
|
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||||
<span className="truncate">{format(parseISO(galleryInfo!.event_date), 'PP')}</span>
|
<span className="px-3 py-1 rounded-full" style={{ backgroundColor: 'var(--color-primary, #5C8762)', opacity: 0.1 }}>
|
||||||
</div>
|
<span style={{ color: 'var(--color-primary, #5C8762)' }}>
|
||||||
|
{t(`events.types.${galleryInfo.event_type}`)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expiration Warning */}
|
{/* Expiration Warning */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -52,10 +52,8 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('7d');
|
const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('7d');
|
||||||
const [isEmbedMode, setIsEmbedMode] = useState(false);
|
const [isEmbedMode, setIsEmbedMode] = useState(false);
|
||||||
|
|
||||||
// Check if Umami is configured
|
// Check if Umami is configured from settings or environment
|
||||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string }>({});
|
||||||
// const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
|
||||||
const umamiShareUrl = import.meta.env.VITE_UMAMI_SHARE_URL;
|
|
||||||
|
|
||||||
// Fetch analytics data from backend
|
// Fetch analytics data from backend
|
||||||
const { data: apiData, isLoading, refetch } = useQuery({
|
const { data: apiData, isLoading, refetch } = useQuery({
|
||||||
@@ -73,6 +71,49 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
queryFn: () => adminService.getDashboardStats(),
|
queryFn: () => adminService.getDashboardStats(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch public settings to get Umami config
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUmamiConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`);
|
||||||
|
const settings = await response.json();
|
||||||
|
|
||||||
|
// Check if Umami is enabled in admin settings
|
||||||
|
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
|
||||||
|
setUmamiConfig({
|
||||||
|
url: settings.umami_url,
|
||||||
|
shareUrl: settings.umami_share_url
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fall back to environment variables if they exist
|
||||||
|
const envUrl = import.meta.env.VITE_UMAMI_URL;
|
||||||
|
const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||||
|
|
||||||
|
if (envUrl && envWebsiteId) {
|
||||||
|
setUmamiConfig({
|
||||||
|
url: envUrl,
|
||||||
|
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch Umami config:', error);
|
||||||
|
// Fall back to environment variables if they exist
|
||||||
|
const envUrl = import.meta.env.VITE_UMAMI_URL;
|
||||||
|
const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||||
|
|
||||||
|
if (envUrl && envWebsiteId) {
|
||||||
|
setUmamiConfig({
|
||||||
|
url: envUrl,
|
||||||
|
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUmamiConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Calculate trends and format data
|
// Calculate trends and format data
|
||||||
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
|
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
|
||||||
if (!apiData) return undefined;
|
if (!apiData) return undefined;
|
||||||
@@ -96,11 +137,15 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
const secondHalfDownloads = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.downloads, 0);
|
const secondHalfDownloads = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.downloads, 0);
|
||||||
const downloadsTrend = firstHalfDownloads > 0 ? ((secondHalfDownloads - firstHalfDownloads) / firstHalfDownloads) * 100 : 0;
|
const downloadsTrend = firstHalfDownloads > 0 ? ((secondHalfDownloads - firstHalfDownloads) / firstHalfDownloads) * 100 : 0;
|
||||||
|
|
||||||
// Format top galleries for downloads
|
// Get actual download data for top galleries - sort by downloads
|
||||||
const topGalleriesWithDownloads = apiData.topGalleries.map(gallery => ({
|
const topGalleriesWithDownloads = apiData.topGalleries
|
||||||
name: gallery.event_name,
|
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
|
||||||
downloads: gallery.views // Using views as download count for now
|
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
|
||||||
}));
|
.slice(0, 5) // Take top 5
|
||||||
|
.map(gallery => ({
|
||||||
|
name: gallery.event_name,
|
||||||
|
downloads: gallery.downloads || 0
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageViews: {
|
pageViews: {
|
||||||
@@ -122,7 +167,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
topPages: apiData.topGalleries.map(gallery => ({
|
topPages: apiData.topGalleries.map(gallery => ({
|
||||||
path: `/gallery/${gallery.slug}`,
|
path: `/gallery/${gallery.slug}`,
|
||||||
views: gallery.views,
|
views: gallery.views,
|
||||||
uniqueVisitors: Math.round(gallery.views * 0.4) // Estimate unique visitors
|
uniqueVisitors: gallery.uniqueVisitors || gallery.views // Use actual unique visitors if available
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}, [apiData]);
|
}, [apiData]);
|
||||||
@@ -166,7 +211,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If Umami is configured and embed mode is enabled, show the Umami dashboard
|
// If Umami is configured and embed mode is enabled, show the Umami dashboard
|
||||||
if (isEmbedMode && umamiShareUrl) {
|
if (isEmbedMode && umamiConfig.shareUrl) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
@@ -185,7 +230,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
|
|
||||||
<Card padding="none" className="overflow-hidden" style={{ height: '800px' }}>
|
<Card padding="none" className="overflow-hidden" style={{ height: '800px' }}>
|
||||||
<iframe
|
<iframe
|
||||||
src={umamiShareUrl}
|
src={umamiConfig.shareUrl}
|
||||||
className="w-full h-full border-0"
|
className="w-full h-full border-0"
|
||||||
title="Umami Analytics Dashboard"
|
title="Umami Analytics Dashboard"
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +248,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
<p className="text-neutral-600 mt-1">{t('analytics.subtitle')}</p>
|
<p className="text-neutral-600 mt-1">{t('analytics.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{umamiShareUrl && (
|
{umamiConfig.shareUrl && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsEmbedMode(true)}
|
onClick={() => setIsEmbedMode(true)}
|
||||||
@@ -405,7 +450,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configuration Notice */}
|
{/* Configuration Notice */}
|
||||||
{!umamiUrl && (
|
{!umamiConfig.url && (
|
||||||
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
|
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
||||||
|
|||||||
@@ -153,13 +153,13 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="w-5 h-5" />
|
<FileText className="w-5 h-5 flex-shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
|
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
|
||||||
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
{selectedPage === page.slug && hasUnsavedChanges && (
|
{selectedPage === page.slug && hasUnsavedChanges && (
|
||||||
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
|
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -235,7 +235,7 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🇬🇧 English
|
English
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingLang('de')}
|
onClick={() => setEditingLang('de')}
|
||||||
@@ -245,7 +245,7 @@ export const CMSPageEnhanced: React.FC = () => {
|
|||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🇩🇪 Deutsch
|
Deutsch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff
|
EyeOff
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format, addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { enUS, de } from 'date-fns/locale';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { Button, Input, Card } from '../../components/common';
|
import { Button, Input, Card } from '../../components/common';
|
||||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { categoriesService } from '../../services/categories.service';
|
import { categoriesService } from '../../services/categories.service';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -57,6 +57,7 @@ const EVENT_TYPES = [
|
|||||||
export const CreateEventPageEnhanced: React.FC = () => {
|
export const CreateEventPageEnhanced: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { format } = useLocalizedDate();
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||||
// const [showPreview, setShowPreview] = useState(false);
|
// const [showPreview, setShowPreview] = useState(false);
|
||||||
@@ -499,7 +500,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{formData.event_date && (
|
{formData.event_date && (
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
<p className="mt-2 text-sm text-neutral-500">
|
||||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP', { locale: i18n.language === 'de' ? de : enUS })}
|
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { settingsService } from '../../services/settings.service';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics'>('general');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [securitySettings, setSecuritySettings] = useState({
|
const [securitySettings, setSecuritySettings] = useState({
|
||||||
require_password: true,
|
require_password: true,
|
||||||
password_min_length: 8,
|
password_min_length: 8,
|
||||||
|
password_complexity: 'moderate',
|
||||||
enable_2fa: false,
|
enable_2fa: false,
|
||||||
session_timeout_minutes: 60,
|
session_timeout_minutes: 60,
|
||||||
max_login_attempts: 5,
|
max_login_attempts: 5,
|
||||||
@@ -72,6 +73,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
recaptcha_secret_key: ''
|
recaptcha_secret_key: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Analytics settings state
|
||||||
|
const [analyticsSettings, setAnalyticsSettings] = useState({
|
||||||
|
umami_enabled: false,
|
||||||
|
umami_url: '',
|
||||||
|
umami_website_id: '',
|
||||||
|
umami_share_url: ''
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
// Set the language if it's different from current
|
// Set the language if it's different from current
|
||||||
@@ -104,8 +113,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
recaptcha_site_key: settings.security_recaptcha_site_key || '',
|
recaptcha_site_key: settings.security_recaptcha_site_key || '',
|
||||||
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
|
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract analytics settings
|
||||||
|
setAnalyticsSettings({
|
||||||
|
umami_enabled: settings.analytics_umami_enabled || false,
|
||||||
|
umami_url: settings.analytics_umami_url || '',
|
||||||
|
umami_website_id: settings.analytics_umami_website_id || '',
|
||||||
|
umami_share_url: settings.analytics_umami_share_url || ''
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings, i18n]);
|
||||||
|
|
||||||
// Save mutations
|
// Save mutations
|
||||||
const saveGeneralMutation = useMutation({
|
const saveGeneralMutation = useMutation({
|
||||||
@@ -144,6 +161,24 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveAnalyticsMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// Convert to the format expected by the API
|
||||||
|
const settingsData: Record<string, any> = {};
|
||||||
|
Object.entries(analyticsSettings).forEach(([key, value]) => {
|
||||||
|
settingsData[`analytics_${key}`] = value;
|
||||||
|
});
|
||||||
|
return settingsService.updateSettings(settingsData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('toast.settingsSaved'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -202,6 +237,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('settings.categories.title')}
|
{t('settings.categories.title')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('analytics')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'analytics'
|
||||||
|
? 'border-primary-600 text-primary-600'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('settings.analytics.title')}
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -621,6 +666,25 @@ export const SettingsPage: React.FC = () => {
|
|||||||
max="32"
|
max="32"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.passwordComplexity')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={securitySettings.password_complexity}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="simple">{t('settings.security.complexitySimple')}</option>
|
||||||
|
<option value="moderate">{t('settings.security.complexityModerate')}</option>
|
||||||
|
<option value="strong">{t('settings.security.complexityStrong')}</option>
|
||||||
|
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.passwordComplexityHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -754,6 +818,127 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Analytics Tab */}
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={analyticsSettings.umami_enabled}
|
||||||
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.analytics.enableUmami')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{analyticsSettings.umami_enabled && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.analytics.umamiUrl')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={analyticsSettings.umami_url}
|
||||||
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
|
||||||
|
placeholder="https://analytics.yourdomain.com"
|
||||||
|
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.analytics.umamiUrlHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.analytics.websiteId')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={analyticsSettings.umami_website_id}
|
||||||
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.analytics.websiteIdHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.analytics.shareUrl')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={analyticsSettings.umami_share_url}
|
||||||
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
|
||||||
|
placeholder="https://analytics.yourdomain.com/share/..."
|
||||||
|
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.analytics.shareUrlHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
|
||||||
|
<p>{t('settings.analytics.umamiInfoText')}</p>
|
||||||
|
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
|
||||||
|
{t('settings.analytics.learnMore')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => saveAnalyticsMutation.mutate()}
|
||||||
|
isLoading={saveAnalyticsMutation.isPending}
|
||||||
|
leftIcon={<Save className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
{t('settings.analytics.saveAnalyticsSettings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Backend Analytics Info */}
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
|
||||||
|
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
|
||||||
|
<ul className="text-xs text-neutral-600 space-y-1">
|
||||||
|
<li>• {t('settings.analytics.galleryViews')}</li>
|
||||||
|
<li>• {t('settings.analytics.photoDownloads')}</li>
|
||||||
|
<li>• {t('settings.analytics.uniqueVisitors')}</li>
|
||||||
|
<li>• {t('settings.analytics.deviceTypes')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
|
||||||
|
<p className="text-xs text-neutral-600">
|
||||||
|
{t('settings.analytics.privacyText')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -162,7 +162,19 @@ export const settingsService = {
|
|||||||
|
|
||||||
// Update multiple settings at once
|
// Update multiple settings at once
|
||||||
async updateSettings(settings: Record<string, any>): Promise<void> {
|
async updateSettings(settings: Record<string, any>): Promise<void> {
|
||||||
await api.put('/admin/settings/general', settings);
|
// Determine the endpoint based on setting keys
|
||||||
|
const firstKey = Object.keys(settings)[0];
|
||||||
|
let endpoint = '/admin/settings/general';
|
||||||
|
|
||||||
|
if (firstKey?.startsWith('security_')) {
|
||||||
|
endpoint = '/admin/settings/security';
|
||||||
|
} else if (firstKey?.startsWith('analytics_')) {
|
||||||
|
endpoint = '/admin/settings/analytics';
|
||||||
|
} else if (firstKey?.startsWith('branding_')) {
|
||||||
|
endpoint = '/admin/settings/branding';
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put(endpoint, settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get storage information
|
// Get storage information
|
||||||
|
|||||||
Reference in New Issue
Block a user