Merge pull request #334 from the-luap/feat/post-319-fixes-and-features

feat: S3 storage + webhooks + settings dedupe + backup fixes
This commit is contained in:
Paul Nothaft
2026-04-28 14:50:35 +02:00
committed by GitHub
74 changed files with 5368 additions and 1077 deletions
+39 -53
View File
@@ -26,14 +26,15 @@ import {
BackupManagement,
CMSPage,
UserManagementPage,
EventTypesPage
EventTypesPage,
WebhookDeliveriesPage
} from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { getApiBaseUrl } from './utils/url';
import { usePublicSettings } from './hooks/usePublicSettings';
// Create a client
const queryClient = new QueryClient({
@@ -45,6 +46,40 @@ const queryClient = new QueryClient({
},
});
// Bootstraps Umami analytics from /public/settings. Lives inside QueryClientProvider
// so it shares the public-settings cache with every other consumer of usePublicSettings.
function AnalyticsBootstrap() {
const { data: settings, isError } = usePublicSettings();
useEffect(() => {
if (!settings && !isError) return;
const envUmamiUrl = import.meta.env.VITE_UMAMI_URL;
const envUmamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (settings?.umami_enabled && settings.umami_url && settings.umami_website_id) {
analyticsService.initialize({
websiteId: settings.umami_website_id,
hostUrl: settings.umami_url,
autoTrack: true,
doNotTrack: true,
});
return;
}
if (envUmamiUrl && envUmamiWebsiteId && (isError || settings?.enable_analytics !== false)) {
analyticsService.initialize({
websiteId: envUmamiWebsiteId,
hostUrl: envUmamiUrl,
autoTrack: true,
doNotTrack: true,
});
}
}, [settings, isError]);
return null;
}
function App() {
// Track dark mode for toast theming
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
@@ -57,60 +92,10 @@ function App() {
return () => observer.disconnect();
}, []);
// Initialize Umami Analytics based on settings
useEffect(() => {
const initializeAnalytics = async () => {
try {
// Fetch public settings to get Umami configuration
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
// Check if Umami is enabled and configured in backend settings
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
// Use backend configuration
analyticsService.initialize({
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;
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
}
} catch (error) {
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({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
}
};
initializeAnalytics();
}, []);
return (
<PageErrorBoundary>
<QueryClientProvider client={queryClient}>
<AnalyticsBootstrap />
<MaintenanceProvider>
<ThemeProvider>
<GlobalThemeProvider>
@@ -148,6 +133,7 @@ function App() {
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="event-types" element={<EventTypesPage />} />
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
<Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPage />} />
<Route path="users" element={<UserManagementPage />} />
@@ -1,7 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTheme } from '../contexts/ThemeContext';
import { api } from '../config/api';
import { usePublicSettings } from '../hooks/usePublicSettings';
interface GlobalThemeProviderProps {
children: React.ReactNode;
@@ -10,22 +9,13 @@ interface GlobalThemeProviderProps {
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
const { setTheme } = useTheme();
const themeAppliedRef = useRef(false);
// Fetch public settings including theme config
const { data: settingsData } = useQuery({
queryKey: ['global-theme-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: settingsData } = usePublicSettings();
// Apply global theme when settings are loaded (but not on gallery pages)
useEffect(() => {
// Skip if we're on a gallery page - gallery pages handle their own themes
const isGalleryPage = window.location.pathname.includes('/gallery/');
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
themeAppliedRef.current = true;
setTheme(settingsData.theme_config);
+3 -28
View File
@@ -1,38 +1,13 @@
import React, { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../config/api';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { buildResourceUrl } from '../utils/url';
interface BrandingSettings {
branding_company_name?: string;
branding_company_tagline?: string;
branding_support_email?: string;
branding_footer_text?: string;
branding_favicon_url?: string;
branding_logo_url?: string;
default_language?: string;
}
export const MaintenanceMode: React.FC = () => {
const { t, i18n } = useTranslation();
// Fetch branding settings
const { data: settings } = useQuery<BrandingSettings>({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await api.get('/public/settings');
return response.data;
} catch {
// Return empty object if settings can't be fetched
return {};
}
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false, // Don't retry on failure
});
const { data: settings } = usePublicSettings({ retry: false });
// Set language based on system settings
useEffect(() => {
+6 -31
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api } from '../config/api';
@@ -9,12 +8,16 @@ interface MaintenanceWrapperProps {
children: React.ReactNode;
}
// Maintenance detection now lives in two places:
// 1. The axios interceptor in config/api.ts flips the flag on any 503 response.
// 2. MaintenanceContext polls /public/settings every 30s and reads the explicit
// maintenance_mode field (via the shared usePublicSettings hook).
// This wrapper only needs to gate the rendered tree on the resulting state.
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
const [hasAdminSession, setHasAdminSession] = useState(false);
// Check if current route is admin route
const isAdminRoute = location.pathname.startsWith('/admin');
useEffect(() => {
@@ -45,40 +48,12 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
};
}, [isAdminRoute]);
// Register the maintenance mode callback
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setMaintenanceMode(enabled);
});
}, [setMaintenanceMode]);
// Check maintenance mode on mount and when location changes
useQuery({
queryKey: ['maintenance-check', location.pathname],
queryFn: async () => {
try {
// Make a lightweight request to check maintenance status
await api.get('/public/settings');
// If successful, maintenance mode is off
setMaintenanceMode(false);
return { maintenance: false };
} catch (error: any) {
if (error.response?.status === 503) {
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminSession) {
setMaintenanceMode(true);
return { maintenance: true };
}
}
return { maintenance: false };
}
},
staleTime: 30000, // Check every 30 seconds
retry: false, // Don't retry on failure
enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance
});
// Show maintenance page if in maintenance mode and not on admin route with auth
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
return <MaintenanceMode />;
}
+3 -11
View File
@@ -9,11 +9,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAdminAuth } from '../../contexts';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
import { buildResourceUrl, getApiBaseUrl } from '../../utils/url';
import { buildResourceUrl } from '../../utils/url';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -31,16 +32,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
// Fetch branding settings
const { data: brandingSettings } = useQuery({
queryKey: ['admin-settings', 'branding'],
queryFn: async () => {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) return response.json();
return null;
},
staleTime: 5 * 60 * 1000,
});
const { data: brandingSettings } = usePublicSettings();
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
@@ -6,7 +6,7 @@ import DOMPurify from 'dompurify';
import { Card } from './Card';
import { Loading } from './Loading';
import { cmsService } from '../../services/cms.service';
import { api } from '../../config/api';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
import '../../styles/prose-overrides.css';
@@ -33,14 +33,7 @@ const ALLOWED_ATTR = ['href', 'target', 'rel', 'class', 'style', 'src', 'alt', '
export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback }) => {
const { i18n } = useTranslation();
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000,
});
const { data: settings } = usePublicSettings();
const lang = settings?.default_language || i18n.language || 'en';
@@ -1,25 +1,11 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const { data: settings } = usePublicSettings({ retry: false });
// Update favicon when branding settings change
useEffect(() => {
@@ -82,4 +68,4 @@ export const DynamicFavicon: React.FC = () => {
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
return null;
};
};
+8 -24
View File
@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface ReCaptchaProps {
onChange: (token: string | null) => void;
@@ -9,31 +8,16 @@ interface ReCaptchaProps {
size?: 'normal' | 'compact';
}
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
onExpired,
size = 'normal'
size = 'normal'
}) => {
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
const [siteKey, setSiteKey] = useState<string>('');
const { data: settings } = usePublicSettings();
// Fetch public settings to get reCAPTCHA site key
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
return response.json();
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const siteKey = settings?.recaptcha_site_key ?? '';
useEffect(() => {
if (settings?.recaptcha_site_key) {
setSiteKey(settings.recaptcha_site_key);
}
}, [settings]);
// If reCAPTCHA is not enabled or site key is not available, return null
if (!settings?.enable_recaptcha || !siteKey) {
return null;
}
@@ -52,4 +36,4 @@ export const ReCaptcha: React.FC<ReCaptchaProps> = ({
);
};
export default ReCaptcha;
export default ReCaptcha;
@@ -1,23 +1,8 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
export const RobotsMetaTags: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000,
});
const { data: settings } = usePublicSettings({ retry: false });
useEffect(() => {
// Remove any existing robots meta tags we previously injected
@@ -25,6 +25,7 @@ import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import type { Photo } from '../../types';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { useQueryClient } from '@tanstack/react-query';
@@ -199,15 +200,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
return () => window.removeEventListener('resize', handleResize);
}, []);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: settingsData } = usePublicSettings();
// Fetch feedback settings
const { data: feedbackSettings } = useQuery({
@@ -1,11 +1,10 @@
import React, { useState, useMemo } from 'react';
import { Upload, X, CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Button } from '../common';
import { api } from '../../config/api';
import { publicSettingsService } from '../../services/publicSettings.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
interface UserPhotoUploadProps {
@@ -26,11 +25,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => publicSettingsService.getPublicSettings(),
staleTime: 5 * 60 * 1000,
});
const { data: publicSettings } = usePublicSettings();
const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
+6 -23
View File
@@ -1,7 +1,6 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { setMaintenanceModeCallback } from '../config/api';
import { getApiBaseUrl } from '../utils/url';
import { usePublicSettings } from '../hooks/usePublicSettings';
interface MaintenanceContextType {
isMaintenanceMode: boolean;
@@ -25,34 +24,18 @@ interface MaintenanceProviderProps {
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
// Check maintenance mode status on mount
const { data: settings } = useQuery({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.status === 503) {
setIsMaintenanceMode(true);
return null;
}
return response.json();
} catch (error) {
// If we can't reach the server, don't assume maintenance mode
return null;
}
},
staleTime: 30 * 1000, // Check every 30 seconds
refetchInterval: 30 * 1000,
});
// Polls /public/settings every 30s so a maintenance flag flipped server-side propagates
// without a refresh. 503 responses are caught by the axios interceptor in config/api.ts
// (which calls setMaintenanceModeCallback below), so we only need to read the explicit
// maintenance_mode flag here.
const { data: settings } = usePublicSettings({ refetchInterval: 30_000 });
// Update maintenance mode based on settings
useEffect(() => {
if (settings?.maintenance_mode !== undefined) {
setIsMaintenanceMode(settings.maintenance_mode);
}
}, [settings]);
// Set up the callback for API interceptor
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setIsMaintenanceMode(enabled);
+1
View File
@@ -16,3 +16,4 @@ export { StylingTab } from './tabs/StylingTab';
export { SEOTab } from './tabs/SEOTab';
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
export { ApiTokensTab } from './tabs/ApiTokensTab';
export { WebhooksTab } from './tabs/WebhooksTab';
@@ -0,0 +1,354 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Webhook as WebhookIcon, Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
const WEBHOOK_EVENT_TYPES = [
'event.created',
'event.published',
'event.archived',
'event.expired',
'photo.uploaded',
'photo.deleted',
] as const;
type WebhookEventType = typeof WEBHOOK_EVENT_TYPES[number];
interface WebhookRow {
id: number;
name: string;
url: string;
events: WebhookEventType[];
active: boolean;
secret_preview: string | null;
created_at: string;
updated_at: string;
last_success_at: string | null;
last_failure_at: string | null;
owner_username: string | null;
}
/**
* Settings → Webhooks tab (#327). Mirrors the API Tokens tab pattern:
* the signing secret is returned exactly once on creation and never
* recoverable. Per-webhook delivery history lives on the dedicated
* /admin/webhooks/:id/deliveries page (link in the table).
*/
export const WebhooksTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [events, setEvents] = useState<WebhookEventType[]>(['event.published']);
const [filterText, setFilterText] = useState('{}');
const [template, setTemplate] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [justCreatedSecret, setJustCreatedSecret] = useState<string | null>(null);
const [filterError, setFilterError] = useState<string | null>(null);
const { data: webhooks, isLoading } = useQuery({
queryKey: ['admin-webhooks'],
queryFn: async () => {
const res = await api.get<WebhookRow[]>('/admin/webhooks');
return res.data;
},
});
const createMutation = useMutation({
mutationFn: async () => {
let parsedFilter: Record<string, unknown> = {};
const trimmed = filterText.trim();
if (trimmed && trimmed !== '{}') {
try {
parsedFilter = JSON.parse(trimmed);
} catch {
setFilterError('Filter must be valid JSON');
throw new Error('Invalid filter JSON');
}
}
setFilterError(null);
const body: Record<string, unknown> = { name, url, events, active: true };
if (Object.keys(parsedFilter).length > 0) body.filter = parsedFilter;
if (template.trim()) body.template = template;
const res = await api.post<{ secret: string }>('/admin/webhooks', body);
return res.data.secret;
},
onSuccess: (secret) => {
setJustCreatedSecret(secret);
setName('');
setUrl('');
setEvents(['event.published']);
setFilterText('{}');
setTemplate('');
setShowAdvanced(false);
queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] });
},
onError: (err: any) => {
toast.error(err?.response?.data?.errors?.[0]?.msg || err?.response?.data?.error || 'Failed to create webhook');
},
});
const toggleActiveMutation = useMutation({
mutationFn: async ({ id, active }: { id: number; active: boolean }) =>
api.put(`/admin/webhooks/${id}`, { active }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] }),
onError: () => toast.error('Failed to update webhook'),
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => api.delete(`/admin/webhooks/${id}`),
onSuccess: () => {
toast.success('Webhook deleted');
queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] });
},
onError: () => toast.error('Failed to delete webhook'),
});
const toggleEvent = (e: WebhookEventType) => {
setEvents((prev) => (prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e]));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<Loading size="lg" />
</div>
);
}
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2 flex items-center gap-2">
<WebhookIcon className="w-5 h-5" />
{t('settings.webhooks.title', 'Webhooks')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.webhooks.subtitle', 'POST event notifications to your URL the moment something happens — gallery published, photo uploaded, event archived, etc. Signed with HMAC-SHA256 in the X-PicPeak-Signature header.')}
</p>
{justCreatedSecret && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4 mb-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200 mb-1">
{t('settings.webhooks.copyNow', 'Copy this signing secret now — it will not be shown again.')}
</p>
<div className="flex items-center gap-2">
<code className="block flex-1 min-w-0 px-3 py-2 bg-white dark:bg-neutral-900 border border-amber-300 dark:border-amber-700 rounded text-xs font-mono break-all">
{justCreatedSecret}
</code>
<Button
size="sm"
variant="outline"
leftIcon={<Copy className="w-4 h-4" />}
onClick={async () => {
try {
await navigator.clipboard.writeText(justCreatedSecret);
toast.success('Copied');
} catch {
toast.error('Copy failed');
}
}}
>
Copy
</Button>
<Button size="sm" variant="ghost" onClick={() => setJustCreatedSecret(null)}>
Dismiss
</Button>
</div>
</div>
</div>
</div>
)}
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.webhooks.name', 'Name')}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. n8n WhatsApp" />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.webhooks.url', 'Receiver URL')}
</label>
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://n8n.example.com/webhook/picpeak" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('settings.webhooks.events', 'Subscribe to events')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{WEBHOOK_EVENT_TYPES.map((e) => (
<label key={e} className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
checked={events.includes(e)}
onChange={() => toggleEvent(e)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<code className="text-xs">{e}</code>
</label>
))}
</div>
</div>
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-start"
>
{showAdvanced ? ' Hide advanced (filter, template)' : '+ Advanced (filter, template)'}
</button>
{showAdvanced && (
<div className="space-y-3 border-l-2 border-neutral-200 dark:border-neutral-700 pl-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.webhooks.filter', 'Filter (JSON, optional)')}
</label>
<textarea
value={filterText}
onChange={(e) => { setFilterText(e.target.value); setFilterError(null); }}
placeholder='{"data.event.event_type": "wedding"}'
rows={3}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 rounded text-sm font-mono"
/>
<p className="text-xs text-neutral-500 mt-1">
Dot-path expected value. All keys must match (AND). Use an array for "any of": <code>{'{"type": ["event.published", "event.archived"]}'}</code>
</p>
{filterError && <p className="text-xs text-red-600 mt-1">{filterError}</p>}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.webhooks.template', 'Template (optional)')}
</label>
<textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder={'New gallery: ${data.event.event_name} → ${data.event.share_url}'}
rows={3}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 rounded text-sm font-mono"
/>
<p className="text-xs text-neutral-500 mt-1">
Replaces the default JSON envelope as the request body. <code>${'{dot.path}'}</code> substitution from the payload only no logic, no expressions.
</p>
</div>
</div>
)}
<Button
variant="primary"
onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending}
disabled={!name.trim() || !url.trim() || events.length === 0}
>
{t('settings.webhooks.create', 'Create Webhook')}
</Button>
</div>
</Card>
<Card padding="md">
<h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
{t('settings.webhooks.existing', 'Existing webhooks')}
</h3>
{webhooks && webhooks.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<th className="py-2 pr-3">Name</th>
<th className="py-2 pr-3">URL</th>
<th className="py-2 pr-3">Events</th>
<th className="py-2 pr-3">Last delivery</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{webhooks.map((wh) => {
const lastSuccess = wh.last_success_at ? new Date(wh.last_success_at) : null;
const lastFailure = wh.last_failure_at ? new Date(wh.last_failure_at) : null;
const lastEither = lastFailure && (!lastSuccess || lastFailure > lastSuccess) ? 'failure' : (lastSuccess ? 'success' : 'none');
return (
<tr key={wh.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0 align-top">
<td className="py-3 pr-3 font-medium">{wh.name}</td>
<td className="py-3 pr-3 text-xs font-mono text-neutral-600 dark:text-neutral-400 max-w-xs truncate" title={wh.url}>{wh.url}</td>
<td className="py-3 pr-3 text-xs text-neutral-500">
{Array.isArray(wh.events) ? wh.events.length : 0} subscribed
</td>
<td className="py-3 pr-3 text-xs text-neutral-500">
{lastEither === 'success' && lastSuccess && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{lastSuccess.toLocaleString()}
</span>
)}
{lastEither === 'failure' && lastFailure && (
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
<XCircle className="w-3.5 h-3.5" />
{lastFailure.toLocaleString()}
</span>
)}
{lastEither === 'none' && <span className="text-neutral-400"></span>}
</td>
<td className="py-3 pr-3">
<button
onClick={() => toggleActiveMutation.mutate({ id: wh.id, active: !wh.active })}
className={`text-xs px-2 py-0.5 rounded ${
wh.active
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400'
}`}
title={wh.active ? 'Click to disable' : 'Click to enable'}
>
{wh.active ? 'Active' : 'Disabled'}
</button>
</td>
<td className="py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/webhooks/${wh.id}/deliveries`}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<Activity className="w-3.5 h-3.5" />
Deliveries
</Link>
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => {
if (confirm(`Delete "${wh.name}"? Pending deliveries are also removed.`)) {
deleteMutation.mutate(wh.id);
}
}}
>
Delete
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.webhooks.empty', 'No webhooks yet. Create one above to start receiving event notifications.')}
</p>
)}
</Card>
</div>
);
};
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { usePublicSettings } from '../usePublicSettings';
import { publicSettingsService } from '../../services/publicSettings.service';
vi.mock('../../services/publicSettings.service', () => ({
publicSettingsService: {
getPublicSettings: vi.fn(),
},
}));
const getPublicSettingsMock = vi.mocked(publicSettingsService.getPublicSettings);
function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return { client, Wrapper };
}
describe('usePublicSettings', () => {
beforeEach(() => {
getPublicSettingsMock.mockReset();
});
it('returns settings from the public settings service', async () => {
getPublicSettingsMock.mockResolvedValue({
branding_company_name: 'PicPeak Test',
maintenance_mode: false,
} as Awaited<ReturnType<typeof publicSettingsService.getPublicSettings>>);
const { Wrapper } = makeWrapper();
const { result } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.branding_company_name).toBe('PicPeak Test');
expect(getPublicSettingsMock).toHaveBeenCalledTimes(1);
});
it('dedupes parallel callers in the same QueryClient', async () => {
getPublicSettingsMock.mockResolvedValue({
branding_company_name: 'PicPeak Test',
} as Awaited<ReturnType<typeof publicSettingsService.getPublicSettings>>);
const { Wrapper } = makeWrapper();
const { result: first } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
const { result: second } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
const { result: third } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
await waitFor(() => {
expect(first.current.isSuccess).toBe(true);
expect(second.current.isSuccess).toBe(true);
expect(third.current.isSuccess).toBe(true);
});
// Single network call regardless of how many components mount the hook.
expect(getPublicSettingsMock).toHaveBeenCalledTimes(1);
});
});
+2 -1
View File
@@ -2,4 +2,5 @@ export * from './useSessionTimeout';
export * from './useOnClickOutside';
export * from './useLocalizedDate';
export * from './useLocalizedTimeAgo';
export * from './usePermission';
export * from './usePermission';
export * from './usePublicSettings';
+2 -9
View File
@@ -1,8 +1,7 @@
import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS, ptBR } from 'date-fns/locale';
import { useQuery } from '@tanstack/react-query';
import { publicSettingsService } from '../services/publicSettings.service';
import { usePublicSettings } from './usePublicSettings';
// Convert old date format strings to new date-fns format
const convertDateFormat = (format: string): string => {
@@ -15,13 +14,7 @@ const convertDateFormat = (format: string): string => {
export const useLocalizedDate = () => {
const { i18n } = useTranslation();
// Fetch public settings to get the date format
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => publicSettingsService.getPublicSettings(),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: 1, // Only retry once to avoid blocking the UI
});
const { data: settings } = usePublicSettings();
const getLocale = () => {
if (i18n.language === 'de') return de;
+18
View File
@@ -0,0 +1,18 @@
import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
import { publicSettingsService, type PublicSettings } from '../services/publicSettings.service';
export const PUBLIC_SETTINGS_QUERY_KEY = ['public-settings'] as const;
type PublicSettingsQueryOptions = Omit<
UseQueryOptions<PublicSettings, Error>,
'queryKey' | 'queryFn'
>;
export function usePublicSettings(options?: PublicSettingsQueryOptions) {
return useQuery<PublicSettings, Error>({
queryKey: PUBLIC_SETTINGS_QUERY_KEY,
queryFn: () => publicSettingsService.getPublicSettings(),
staleTime: 60_000,
...options,
});
}
+7 -24
View File
@@ -1,27 +1,10 @@
import { useState, useEffect } from 'react';
import { api } from '../config/api';
import { usePublicSettings } from './usePublicSettings';
export function useWatermarkSettings() {
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const { data: settings, isLoading } = usePublicSettings();
useEffect(() => {
const fetchSettings = async () => {
try {
// Use public settings endpoint that doesn't require authentication
const response = await api.get('/public/settings');
setWatermarkEnabled(response.data.branding_watermark_enabled || false);
} catch (error) {
console.error('Failed to fetch watermark settings:', error);
// Default to false if we can't fetch settings
setWatermarkEnabled(false);
} finally {
setLoading(false);
}
};
fetchSettings();
}, []);
return { watermarkEnabled, loading };
}
return {
watermarkEnabled: Boolean(settings?.branding_watermark_enabled),
loading: isLoading,
};
}
+2 -10
View File
@@ -2,12 +2,11 @@ import React, { useState } from 'react';
import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom';
import { AlertCircle, Lock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { api } from '../config/api';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { buildResourceUrl } from '../utils/url';
export const ClientAccessPage: React.FC = () => {
@@ -22,14 +21,7 @@ export const ClientAccessPage: React.FC = () => {
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug);
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000,
});
const { data: settingsData } = usePublicSettings();
// If already authenticated as client, redirect to gallery
React.useEffect(() => {
+2 -11
View File
@@ -4,7 +4,7 @@ import { AlertCircle, Clock } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { Card, CardContent, Input, Button, ReCaptcha, CMSContentBlock } from '../components/common';
import { useGalleryAuth, useTheme } from '../contexts';
@@ -13,7 +13,6 @@ import { GalleryView } from '../components/gallery';
import { GallerySkeleton } from '../components/gallery/GallerySkeleton';
import { analyticsService } from '../services/analytics.service';
import { galleryService } from '../services';
import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
@@ -106,15 +105,7 @@ export const GalleryPage: React.FC = () => {
setAutoLoginAttempted(false);
}, [resolvedSlug]);
// Fetch branding settings
const { data: settingsData, isLoading: isLoadingSettings } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: settingsData, isLoading: isLoadingSettings } = usePublicSettings();
// Set language from admin settings when on login page
React.useEffect(() => {
+2 -10
View File
@@ -2,12 +2,12 @@ import React, { useState, useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { api } from '../../config/api';
export const AdminLoginPage: React.FC = () => {
@@ -25,15 +25,7 @@ export const AdminLoginPage: React.FC = () => {
const [loginSuccess, setLoginSuccess] = useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch branding settings (unauthenticated)
const { data: settingsData } = useQuery({
queryKey: ['admin-login-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: settingsData } = usePublicSettings();
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
+2 -6
View File
@@ -22,7 +22,7 @@ import { eventsService } from '../../services/events.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { publicSettingsService } from '../../services/publicSettings.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { cssTemplatesService } from '../../services/cssTemplates.service';
import { eventTypesService } from '../../services/eventTypes.service';
import { useTranslation } from 'react-i18next';
@@ -170,11 +170,7 @@ export const CreateEventPage: React.FC = () => {
queryFn: () => settingsService.getAllSettings()
});
// Fetch public settings for field requirements
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => publicSettingsService.getPublicSettings()
});
const { data: publicSettings } = usePublicSettings();
// Get field requirements (default to true if not set)
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
+34 -7
View File
@@ -56,7 +56,7 @@ import { Button, Input, Card, Loading } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { publicSettingsService } from '../../services/publicSettings.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { api } from '../../config/api';
import { buildResourceUrl, buildShareLinkUrl } from '../../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
@@ -160,6 +160,7 @@ export const EventDetailsPage: React.FC = () => {
disable_right_click: boolean;
allow_downloads: boolean;
watermark_downloads: boolean;
allow_presigned_download: boolean;
enable_devtools_protection: boolean;
use_canvas_rendering: boolean;
// Hero logo settings
@@ -196,6 +197,7 @@ export const EventDetailsPage: React.FC = () => {
disable_right_click: true,
allow_downloads: true,
watermark_downloads: false,
allow_presigned_download: false,
enable_devtools_protection: true,
use_canvas_rendering: false,
// Hero logo settings
@@ -334,11 +336,7 @@ export const EventDetailsPage: React.FC = () => {
}
}, [showMediaFilter, photoFilters.media_type]);
// Fetch public settings (for field requirement checks like expiration)
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => publicSettingsService.getPublicSettings(),
});
const { data: publicSettings } = usePublicSettings();
const requireExpiration = publicSettings?.event_require_expiration !== false;
const phoneFieldEnabled = publicSettings?.event_phone_field_enabled === true;
@@ -444,6 +442,7 @@ export const EventDetailsPage: React.FC = () => {
disable_right_click: event.disable_right_click ?? true,
allow_downloads: event.allow_downloads ?? true,
watermark_downloads: event.watermark_downloads ?? false,
allow_presigned_download: (event as { allow_presigned_download?: boolean }).allow_presigned_download ?? false,
enable_devtools_protection: event.enable_devtools_protection ?? true,
use_canvas_rendering: event.use_canvas_rendering ?? false,
// Load hero logo settings from event
@@ -581,6 +580,7 @@ export const EventDetailsPage: React.FC = () => {
disable_right_click: editForm.disable_right_click,
allow_downloads: editForm.allow_downloads,
watermark_downloads: editForm.watermark_downloads,
allow_presigned_download: editForm.allow_presigned_download,
enable_devtools_protection: editForm.enable_devtools_protection,
use_canvas_rendering: editForm.use_canvas_rendering,
// Hero logo settings
@@ -1277,13 +1277,40 @@ export const EventDetailsPage: React.FC = () => {
<input
type="checkbox"
checked={editForm.watermark_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, watermark_downloads: e.target.checked }))}
onChange={(e) => setEditForm(prev => ({
...prev,
watermark_downloads: e.target.checked,
// Watermarking and presigned URLs are mutually
// exclusive — presigned URLs serve raw bytes from
// S3 without going through the watermark pipeline.
allow_presigned_download: e.target.checked ? false : prev.allow_presigned_download,
}))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Droplets className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
</label>
<label
className={`flex items-center ${editForm.watermark_downloads ? 'opacity-50 cursor-not-allowed' : ''}`}
title={editForm.watermark_downloads
? 'Disabled while watermarks are on — presigned URLs bypass the watermark pipeline.'
: 'When the backend uses STORAGE_BACKEND=s3, "Download All" returns a 5-minute presigned S3 URL instead of streaming through the backend. Saves bandwidth on huge galleries; bypasses watermarking.'
}
>
<input
type="checkbox"
checked={!!editForm.allow_presigned_download}
disabled={editForm.watermark_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_presigned_download: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('events.allowPresignedDownload', 'Allow direct S3 download (no watermark, S3 mode only)')}
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
+4 -1
View File
@@ -15,9 +15,10 @@ import {
SEOTab,
ThumbnailsTab,
ApiTokensTab,
WebhooksTab,
} from '../../features/settings';
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling' | 'apiTokens';
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling' | 'apiTokens' | 'webhooks';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('general');
@@ -83,6 +84,7 @@ export const SettingsPage: React.FC = () => {
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
{ key: 'styling', label: t('settings.styling.title', 'Custom CSS') },
{ key: 'apiTokens', label: t('settings.apiTokens.title', 'API Tokens') },
{ key: 'webhooks', label: t('settings.webhooks.title', 'Webhooks') },
];
return (
@@ -189,6 +191,7 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'styling' && <StylingTab />}
{activeTab === 'apiTokens' && <ApiTokensTab />}
{activeTab === 'webhooks' && <WebhooksTab />}
</div>
);
};
@@ -0,0 +1,365 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, RefreshCw, RotateCw, Send, X, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
import { Button, Card, Loading } from '../../components/common';
import { api } from '../../config/api';
const WEBHOOK_EVENT_TYPES = [
'event.created',
'event.published',
'event.archived',
'event.expired',
'photo.uploaded',
'photo.deleted',
] as const;
interface DeliveryRow {
id: number;
event_type: string;
attempt_count: number;
status: 'pending' | 'success' | 'failed';
response_status: number | null;
latency_ms: number | null;
next_retry_at: string | null;
created_at: string;
completed_at: string | null;
last_error: string | null;
}
interface DeliveryDetail extends DeliveryRow {
webhook_id: number;
payload: Record<string, unknown>;
response_body: string | null;
}
interface WebhookDetail {
id: number;
name: string;
url: string;
events: string[];
active: boolean;
}
const STATUS_FILTERS = ['all', 'pending', 'success', 'failed'] as const;
type StatusFilter = typeof STATUS_FILTERS[number];
function statusBadge(status: string) {
const map: Record<string, string> = {
success: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
pending: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
failed: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',
};
return map[status] || 'bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400';
}
/**
* Operational view for #327 the rich debug surface that the Settings
* Webhooks tab links into. Without this page every "is my webhook
* working?" question becomes a support ticket, exactly what Stripe and
* GitHub avoid by shipping a similar split.
*/
export const WebhookDeliveriesPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const webhookId = parseInt(id || '', 10);
const queryClient = useQueryClient();
const [filter, setFilter] = useState<StatusFilter>('all');
const [openDeliveryId, setOpenDeliveryId] = useState<number | null>(null);
const [showTestDialog, setShowTestDialog] = useState(false);
const [testEventType, setTestEventType] = useState<string>('event.published');
const { data: webhook, isLoading: loadingWebhook } = useQuery({
queryKey: ['admin-webhook', webhookId],
queryFn: async () => {
const res = await api.get<WebhookDetail>(`/admin/webhooks/${webhookId}`);
return res.data;
},
enabled: Number.isFinite(webhookId),
});
// Auto-refresh every 10s — tight enough that admins see new attempts land
// without manual reload, loose enough not to thrash the backend.
const deliveriesQuery = useQuery({
queryKey: ['admin-webhook-deliveries', webhookId, filter],
queryFn: async () => {
const params: Record<string, string> = { limit: '50' };
if (filter !== 'all') params.status = filter;
const res = await api.get<{ deliveries: DeliveryRow[]; pagination: { total: number } }>(
`/admin/webhooks/${webhookId}/deliveries`,
{ params }
);
return res.data;
},
enabled: Number.isFinite(webhookId),
refetchInterval: 10_000,
refetchOnWindowFocus: 'always',
});
const detailQuery = useQuery({
queryKey: ['admin-webhook-delivery', webhookId, openDeliveryId],
queryFn: async () => {
const res = await api.get<DeliveryDetail>(`/admin/webhooks/${webhookId}/deliveries/${openDeliveryId}`);
return res.data;
},
enabled: Number.isFinite(webhookId) && openDeliveryId !== null,
});
const replayMutation = useMutation({
mutationFn: async (deliveryId: number) =>
api.post(`/admin/webhooks/${webhookId}/deliveries/${deliveryId}/replay`),
onSuccess: () => {
toast.success('Replay enqueued');
queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] });
},
onError: () => toast.error('Failed to replay'),
});
const testMutation = useMutation({
mutationFn: async () => api.post(`/admin/webhooks/${webhookId}/test`, { event_type: testEventType }),
onSuccess: () => {
toast.success('Test event enqueued');
setShowTestDialog(false);
queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] });
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to send test'),
});
if (loadingWebhook) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" />
</div>
);
}
if (!webhook) {
return (
<div className="p-6">
<p className="text-sm text-neutral-600 dark:text-neutral-400">Webhook not found.</p>
<Link to="/admin/settings" className="text-primary-600 hover:underline"> Back to settings</Link>
</div>
);
}
const deliveries = deliveriesQuery.data?.deliveries || [];
const total = deliveriesQuery.data?.pagination.total || 0;
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<Link
to="/admin/settings"
className="inline-flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 mb-2"
>
<ArrowLeft className="w-4 h-4" />
Back to Settings
</Link>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{webhook.name}</h1>
<p className="text-sm font-mono text-neutral-500 dark:text-neutral-400 mt-1 break-all">{webhook.url}</p>
<div className="mt-2 flex items-center gap-2 flex-wrap">
{webhook.events.map((e) => (
<span key={e} className="text-xs px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 font-mono">
{e}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
leftIcon={<Send className="w-4 h-4" />}
onClick={() => setShowTestDialog(true)}
>
Send test event
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={<RefreshCw className="w-4 h-4" />}
onClick={() => deliveriesQuery.refetch()}
>
Refresh
</Button>
</div>
</div>
<Card padding="md">
<div className="flex items-center gap-2 mb-3">
{STATUS_FILTERS.map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`text-xs px-3 py-1 rounded-full ${
filter === s
? 'bg-primary-600 text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200'
}`}
>
{s}
</button>
))}
<span className="ml-auto text-xs text-neutral-500">{total} total</span>
</div>
{deliveriesQuery.isLoading ? (
<Loading size="md" />
) : deliveries.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-8 text-center">
No deliveries yet. Create an event or send a test event to see something here.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<th className="py-2 pr-3">Time</th>
<th className="py-2 pr-3">Event</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 pr-3">Attempts</th>
<th className="py-2 pr-3">HTTP</th>
<th className="py-2 pr-3">Latency</th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{deliveries.map((d) => (
<tr
key={d.id}
className="border-b border-neutral-100 dark:border-neutral-800 last:border-0 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/40"
onClick={() => setOpenDeliveryId(d.id)}
>
<td className="py-2.5 pr-3 text-xs text-neutral-600 dark:text-neutral-400">
{new Date(d.created_at).toLocaleString()}
</td>
<td className="py-2.5 pr-3 font-mono text-xs">{d.event_type}</td>
<td className="py-2.5 pr-3">
<span className={`text-xs px-2 py-0.5 rounded ${statusBadge(d.status)}`}>
{d.status === 'success' && <CheckCircle2 className="w-3 h-3 inline mr-1" />}
{d.status === 'pending' && <Clock className="w-3 h-3 inline mr-1" />}
{d.status === 'failed' && <AlertCircle className="w-3 h-3 inline mr-1" />}
{d.status}
</span>
</td>
<td className="py-2.5 pr-3 text-xs">{d.attempt_count}</td>
<td className="py-2.5 pr-3 text-xs font-mono">{d.response_status ?? '—'}</td>
<td className="py-2.5 pr-3 text-xs text-neutral-500">{d.latency_ms != null ? `${d.latency_ms}ms` : '—'}</td>
<td className="py-2.5 text-right">
{d.status === 'failed' && (
<Button
size="sm"
variant="ghost"
leftIcon={<RotateCw className="w-3.5 h-3.5" />}
onClick={(e) => {
e.stopPropagation();
replayMutation.mutate(d.id);
}}
>
Replay
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Slide-over with delivery detail */}
{openDeliveryId !== null && (
<div className="fixed inset-0 z-40 flex">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setOpenDeliveryId(null)}
/>
<div className="relative ml-auto w-full max-w-2xl h-full bg-white dark:bg-neutral-900 shadow-xl overflow-y-auto p-6">
<div className="flex items-start justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Delivery #{openDeliveryId}
</h2>
<Button size="sm" variant="ghost" onClick={() => setOpenDeliveryId(null)}>
<X className="w-4 h-4" />
</Button>
</div>
{detailQuery.isLoading || !detailQuery.data ? (
<Loading size="md" />
) : (
<div className="space-y-4 text-sm">
<div>
<span className="block text-xs text-neutral-500">Event type</span>
<code className="text-sm">{detailQuery.data.event_type}</code>
</div>
<div>
<span className="block text-xs text-neutral-500">Status</span>
<span className={`text-xs px-2 py-0.5 rounded ${statusBadge(detailQuery.data.status)}`}>
{detailQuery.data.status}
</span>
</div>
{detailQuery.data.last_error && (
<div>
<span className="block text-xs text-neutral-500">Last error</span>
<pre className="text-xs whitespace-pre-wrap break-words bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded p-2">
{detailQuery.data.last_error}
</pre>
</div>
)}
{detailQuery.data.response_status != null && (
<div>
<span className="block text-xs text-neutral-500">Response status</span>
<code className="text-sm">{detailQuery.data.response_status}</code>
</div>
)}
{detailQuery.data.response_body && (
<div>
<span className="block text-xs text-neutral-500">Response body (truncated to 1KB)</span>
<pre className="text-xs whitespace-pre-wrap break-words bg-neutral-50 dark:bg-neutral-800 rounded p-2 max-h-40 overflow-y-auto">
{detailQuery.data.response_body}
</pre>
</div>
)}
<div>
<span className="block text-xs text-neutral-500">Payload (signed body)</span>
<pre className="text-xs whitespace-pre-wrap break-words bg-neutral-50 dark:bg-neutral-800 rounded p-2 max-h-80 overflow-y-auto">
{JSON.stringify(detailQuery.data.payload, null, 2)}
</pre>
</div>
</div>
)}
</div>
</div>
)}
{/* Test event dialog */}
{showTestDialog && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={() => setShowTestDialog(false)}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-3">Send test event</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
Fires a synthetic delivery to your receiver with a stub payload, no actual side effects.
</p>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Event type</label>
<select
value={testEventType}
onChange={(e) => setTestEventType(e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 rounded text-sm mb-4"
>
{WEBHOOK_EVENT_TYPES.map((e) => <option key={e} value={e}>{e}</option>)}
</select>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowTestDialog(false)}>Cancel</Button>
<Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
Send
</Button>
</div>
</div>
</div>
)}
</div>
);
};
+2 -1
View File
@@ -12,4 +12,5 @@ export { CMSPage } from './CMSPage';
export { BackupManagement } from './BackupManagement';
export { EventFeedbackPage } from './EventFeedbackPage';
export { UserManagementPage } from './UserManagementPage';
export { EventTypesPage } from './EventTypesPage';
export { EventTypesPage } from './EventTypesPage';
export { WebhookDeliveriesPage } from './WebhookDeliveriesPage';
+2 -10
View File
@@ -6,7 +6,7 @@ import { ArrowLeft, Home } from 'lucide-react';
import DOMPurify from 'dompurify';
import { Loading, Card } from '../../components/common';
import { cmsService } from '../../services/cms.service';
import { api } from '../../config/api';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import '../../styles/prose-overrides.css';
export const LegalPage: React.FC = () => {
@@ -18,15 +18,7 @@ export const LegalPage: React.FC = () => {
const pathname = window.location.pathname;
const pageSlug = slug || pathname.split('/').pop() || '';
// Fetch settings to get default language
const { data: settingsData } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: settingsData } = usePublicSettings();
// Use admin settings language
const lang = settingsData?.default_language || 'en';
@@ -12,6 +12,13 @@ export interface PublicSettings {
branding_watermark_size: number;
branding_favicon_url: string;
branding_logo_url: string;
branding_logo_size?: string;
branding_logo_max_height?: number;
branding_logo_position?: 'left' | 'center' | 'right';
branding_logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
branding_logo_display_header?: boolean;
branding_logo_display_hero?: boolean;
branding_hide_powered_by?: boolean;
theme_config: any;
default_language: string;
enable_analytics: boolean;
@@ -34,6 +41,10 @@ export interface PublicSettings {
event_default_require_password?: boolean;
gallery_show_filter_bar?: boolean;
event_phone_field_enabled?: boolean;
// SEO meta tags (consumed by RobotsMetaTags)
seo_meta_noindex?: boolean;
seo_meta_nofollow?: boolean;
seo_meta_noai?: boolean;
}
export const publicSettingsService = {