fix: implement 9 production enhancements and security fixes

- Password Complexity: Added 4-level complexity selector (Simple/Moderate/Strong/Very Strong) in admin security settings with dynamic backend validation
- Gallery Security: Removed event date from login page (security risk), replaced with event type badge
- Analytics Config: Fixed "Not Configured" detection logic to check both admin settings and env variables
- Analytics Accuracy: Aligned calculation logic between dashboard and analytics endpoints, added totals verification
- Translations: Added missing activity keys (analytics_settings_updated, cms_page_updated, security_settings_updated, password_reset, admin_logout, system_activity)
- UI Fixes: Fixed German text overflow in CMS page selector with proper CSS truncation
- Date Format: Event creation now respects admin-configured date format instead of browser locale
- Chrome Compatibility: Replaced emoji flags with SVG components for Windows Chrome support

All changes maintain backward compatibility and production stability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-20 20:27:28 +02:00
parent 4966bc6a58
commit 7aca927937
11 changed files with 233 additions and 44 deletions
+24 -1
View File
@@ -310,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);
+85 -11
View File
@@ -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) * Validate password for specific contexts (admin, gallery)
* @param {string} password - Password to validate * @param {string} password - Password to validate
@@ -120,19 +195,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 +353,7 @@ module.exports = {
generateSecurePassword, generateSecurePassword,
getBcryptRounds, getBcryptRounds,
logPasswordValidationFailure, logPasswordValidationFailure,
getPasswordComplexitySettings,
getPasswordConfigForComplexity,
PASSWORD_CONFIG PASSWORD_CONFIG
}; };
@@ -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>
))} ))}
+15 -3
View File
@@ -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'
}; };
}; };
+12
View File
@@ -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"
} }
}, },
+12
View File
@@ -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",
@@ -796,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"
} }
}, },
+10 -5
View File
@@ -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 */}
+22 -11
View File
@@ -78,25 +78,36 @@ export const AnalyticsPage: React.FC = () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`); const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`);
const settings = await response.json(); 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({ setUmamiConfig({
url: settings.umami_url, url: settings.umami_url,
shareUrl: settings.umami_share_url shareUrl: settings.umami_share_url
}); });
} else { } else {
// Fall back to environment variables // Fall back to environment variables if they exist
setUmamiConfig({ const envUrl = import.meta.env.VITE_UMAMI_URL;
url: import.meta.env.VITE_UMAMI_URL, const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
}); if (envUrl && envWebsiteId) {
setUmamiConfig({
url: envUrl,
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
});
}
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch Umami config:', error); console.error('Failed to fetch Umami config:', error);
// Fall back to environment variables // Fall back to environment variables if they exist
setUmamiConfig({ const envUrl = import.meta.env.VITE_UMAMI_URL;
url: import.meta.env.VITE_UMAMI_URL, const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
}); if (envUrl && envWebsiteId) {
setUmamiConfig({
url: envUrl,
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
});
}
} }
}; };
+6 -6
View File
@@ -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>
+20
View File
@@ -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,
@@ -665,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>