diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js
index 1226087..cf39bcf 100644
--- a/backend/src/routes/adminDashboard.js
+++ b/backend/src/routes/adminDashboard.js
@@ -310,10 +310,33 @@ router.get('/analytics', adminAuth, async (req, res) => {
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({
chartData: dates,
topGalleries,
- devices
+ devices,
+ totals: {
+ views: totalViews?.count || 0,
+ downloads: totalDownloadsCount?.count || 0,
+ uniqueVisitors: totalUniqueVisitors?.count || 0
+ }
});
} catch (error) {
console.error('Analytics error:', error);
diff --git a/backend/src/utils/passwordValidation.js b/backend/src/utils/passwordValidation.js
index 4274cfd..1565477 100644
--- a/backend/src/utils/passwordValidation.js
+++ b/backend/src/utils/passwordValidation.js
@@ -113,6 +113,81 @@ function validatePassword(password, options = {}) {
};
}
+/**
+ * Get complexity settings from database
+ * @returns {Object} - Password complexity configuration
+ */
+async function getPasswordComplexitySettings() {
+ try {
+ const db = require('../db');
+ const settings = await db('app_settings')
+ .where('setting_key', 'password_complexity')
+ .where('setting_type', 'security')
+ .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
+ }
+}
+
+/**
+ * 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)
* @param {string} password - Password to validate
@@ -120,19 +195,16 @@ function validatePassword(password, options = {}) {
* @param {Object} userData - Additional user data for context-aware validation
* @returns {Object} - Validation result
*/
-function validatePasswordInContext(password, context, userData = {}) {
- // For gallery context, use more lenient validation
+async function validatePasswordInContext(password, context, userData = {}) {
+ // For gallery context, use dynamic complexity settings
if (context === 'gallery') {
- // Gallery-specific validation options
+ // Get complexity settings from database
+ const complexityLevel = await getPasswordComplexitySettings();
+
+ // Get configuration for the complexity level
const galleryOptions = {
- minLength: 6, // Reduced minimum length
- requireUppercase: false, // Don't require uppercase for galleries
- 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
+ ...getPasswordConfigForComplexity(complexityLevel),
+ skipStrengthCheck: complexityLevel === 'simple' // Skip zxcvbn for simple passwords
};
// Base validation with gallery-specific options
@@ -281,5 +353,7 @@ module.exports = {
generateSecurePassword,
getBcryptRounds,
logPasswordValidationFailure,
+ getPasswordComplexitySettings,
+ getPasswordConfigForComplexity,
PASSWORD_CONFIG
};
\ No newline at end of file
diff --git a/frontend/src/components/common/LanguageSelector.tsx b/frontend/src/components/common/LanguageSelector.tsx
index 36a9d97..d2d6442 100644
--- a/frontend/src/components/common/LanguageSelector.tsx
+++ b/frontend/src/components/common/LanguageSelector.tsx
@@ -2,9 +2,28 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
+// SVG Flag Components
+const GBFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
+
+);
+
+const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
+
+);
+
const languages = [
- { code: 'en', name: 'English', flag: '🇬🇧' },
- { code: 'de', name: 'Deutsch', flag: '🇩🇪' },
+ { code: 'en', name: 'English', Flag: GBFlag },
+ { code: 'de', name: 'Deutsch', Flag: DEFlag },
];
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"
>
- {currentLanguage.flag}
+
{currentLanguage.name}
@@ -41,7 +60,7 @@ export const LanguageSelector: React.FC = () => {
: 'text-neutral-700'
}`}
>
- {language.flag}
+
{language.name}
))}
diff --git a/frontend/src/hooks/useLocalizedDate.ts b/frontend/src/hooks/useLocalizedDate.ts
index 81c8e1f..4ba5af3 100644
--- a/frontend/src/hooks/useLocalizedDate.ts
+++ b/frontend/src/hooks/useLocalizedDate.ts
@@ -1,17 +1,28 @@
import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
+import { useQuery } from '@tanstack/react-query';
+import { settingsService } from '../services/settings.service';
export const useLocalizedDate = () => {
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 = () => {
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;
- 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 }) => {
@@ -22,6 +33,7 @@ export const useLocalizedDate = () => {
return {
format,
formatDistanceToNow,
- locale: getLocale()
+ locale: getLocale(),
+ dateFormat: settings?.general_date_format || 'PPP'
};
};
\ No newline at end of file
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index a7fd9a4..1e8e2bb 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -428,6 +428,12 @@
"requirePassword": "Passwort für alle Galerien erforderlich",
"minPasswordLength": "Minimale Passwortlänge",
"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",
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
@@ -721,6 +727,12 @@
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
"general_settings_updated": "Allgemeine Einstellungen aktualisiert",
"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"
}
},
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 191f5b6..dd7bc28 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -447,6 +447,12 @@
"requirePassword": "Require password for all galleries",
"minPasswordLength": "Minimum Password Length",
"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",
"sessionTimeout": "Session Timeout (minutes)",
"sessionTimeoutHelp": "Admin session timeout in minutes",
@@ -796,6 +802,12 @@
"category_deleted": "Category deleted: {{categoryName}}",
"general_settings_updated": "General settings updated",
"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"
}
},
diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx
index 9f48e4c..693f7d6 100644
--- a/frontend/src/pages/GalleryPage.tsx
+++ b/frontend/src/pages/GalleryPage.tsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
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 { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
@@ -292,10 +292,15 @@ export const GalleryPage: React.FC = () => {
{galleryInfo?.event_name}
-
-
- {format(parseISO(galleryInfo!.event_date), 'PP')}
-
+ {galleryInfo?.event_type && (
+
+
+
+ {t(`events.types.${galleryInfo.event_type}`)}
+
+
+
+ )}
{/* Expiration Warning */}
diff --git a/frontend/src/pages/admin/AnalyticsPage.tsx b/frontend/src/pages/admin/AnalyticsPage.tsx
index a1964cd..926113d 100644
--- a/frontend/src/pages/admin/AnalyticsPage.tsx
+++ b/frontend/src/pages/admin/AnalyticsPage.tsx
@@ -78,25 +78,36 @@ export const AnalyticsPage: React.FC = () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`);
const settings = await response.json();
- if (settings.umami_enabled) {
+ // 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
- setUmamiConfig({
- url: import.meta.env.VITE_UMAMI_URL,
- shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
- });
+ // 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
- setUmamiConfig({
- url: import.meta.env.VITE_UMAMI_URL,
- shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
- });
+ // 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
+ });
+ }
}
};
diff --git a/frontend/src/pages/admin/CMSPageEnhanced.tsx b/frontend/src/pages/admin/CMSPageEnhanced.tsx
index 320cdd6..74e3534 100644
--- a/frontend/src/pages/admin/CMSPageEnhanced.tsx
+++ b/frontend/src/pages/admin/CMSPageEnhanced.tsx
@@ -153,13 +153,13 @@ export const CMSPageEnhanced: React.FC = () => {
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
}`}
>
-
-
-
{t(`legal.${page.slug}`)}
+
+
+
{t(`legal.${page.slug}`)}
/{page.slug}
{selectedPage === page.slug && hasUnsavedChanges && (
-
+
)}
))}
@@ -235,7 +235,7 @@ export const CMSPageEnhanced: React.FC = () => {
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
- 🇬🇧 English
+ English
diff --git a/frontend/src/pages/admin/CreateEventPageEnhanced.tsx b/frontend/src/pages/admin/CreateEventPageEnhanced.tsx
index 44d4b85..f9f9676 100644
--- a/frontend/src/pages/admin/CreateEventPageEnhanced.tsx
+++ b/frontend/src/pages/admin/CreateEventPageEnhanced.tsx
@@ -10,14 +10,14 @@ import {
Eye,
EyeOff
} from 'lucide-react';
-import { format, addDays } from 'date-fns';
-import { enUS, de } from 'date-fns/locale';
+import { addDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
+import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
@@ -57,6 +57,7 @@ const EVENT_TYPES = [
export const CreateEventPageEnhanced: React.FC = () => {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
+ const { format } = useLocalizedDate();
const isMountedRef = useRef(true);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
// const [showPreview, setShowPreview] = useState(false);
@@ -499,7 +500,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
{formData.event_date && (
- {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))}
)}
diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx
index 00f46a1..f467995 100644
--- a/frontend/src/pages/admin/SettingsPage.tsx
+++ b/frontend/src/pages/admin/SettingsPage.tsx
@@ -64,6 +64,7 @@ export const SettingsPage: React.FC = () => {
const [securitySettings, setSecuritySettings] = useState({
require_password: true,
password_min_length: 8,
+ password_complexity: 'moderate',
enable_2fa: false,
session_timeout_minutes: 60,
max_login_attempts: 5,
@@ -665,6 +666,25 @@ export const SettingsPage: React.FC = () => {
max="32"
/>
+
+
+
+
+
+ {t('settings.security.passwordComplexityHelp')}
+
+