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:
+39
-53
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,4 +2,5 @@ export * from './useSessionTimeout';
|
||||
export * from './useOnClickOutside';
|
||||
export * from './useLocalizedDate';
|
||||
export * from './useLocalizedTimeAgo';
|
||||
export * from './usePermission';
|
||||
export * from './usePermission';
|
||||
export * from './usePublicSettings';
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,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(() => {
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user