From 27437e77436f3e11b63a85e0ce7b9a5653d3fa8c Mon Sep 17 00:00:00 2001 From: LamsOfJos Date: Mon, 17 Aug 2026 20:43:45 +0100 Subject: [PATCH 1/2] feat:webhook-subscription-ui --- frontend/src/App.tsx | 9 + frontend/src/locales/en/translation.json | 28 ++- frontend/src/locales/es/translation.json | 28 ++- frontend/src/pages/Settings.tsx | 17 ++ frontend/src/pages/WebhookSettings.tsx | 250 +++++++++++++++++++++++ frontend/src/services/webhookApi.ts | 45 ++++ 6 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/WebhookSettings.tsx create mode 100644 frontend/src/services/webhookApi.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a44ff014..35d47f8f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import HelpCenter from './pages/HelpCenter'; import ErrorBoundary from './components/ErrorBoundary'; import ErrorFallback from './components/ErrorFallback'; import Settings from './pages/Settings'; +import WebhookSettings from './pages/WebhookSettings'; import CustomReportBuilder from './pages/CustomReportBuilder'; import CrossAssetPayment from './pages/CrossAssetPayment'; import TransactionHistory from './pages/TransactionHistory'; @@ -151,6 +152,14 @@ function App() { } /> + {}} />}> + + + } + /> + + +
+
+ +
+
+

{t('settings.webhooksLabel')}

+

{t('settings.webhooksDescription')}

+
+
+ ); } diff --git a/frontend/src/pages/WebhookSettings.tsx b/frontend/src/pages/WebhookSettings.tsx new file mode 100644 index 00000000..c6979c38 --- /dev/null +++ b/frontend/src/pages/WebhookSettings.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Webhook, Plus, Trash2, Loader2 } from 'lucide-react'; +import { + fetchWebhookSubscriptions, + createWebhookSubscription, + deleteWebhookSubscription, + type WebhookSubscription, +} from '../services/webhookApi.js'; + +const AVAILABLE_EVENTS = [ + 'payment.completed', + 'payment.failed', + 'payroll.processed', + 'employee.added', + 'employee.removed', +]; + +export default function WebhookSettings() { + const { t } = useTranslation(); + + const [subscriptions, setSubscriptions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [url, setUrl] = useState(''); + const [secret, setSecret] = useState(''); + const [selectedEvents, setSelectedEvents] = useState([]); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const loadSubscriptions = async () => { + setIsLoading(true); + setError(null); + try { + const data = await fetchWebhookSubscriptions(); + setSubscriptions(data); + } catch (loadError) { + setError( + loadError instanceof Error ? loadError.message : t('webhooks.errors.loadFailed') + ); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void loadSubscriptions(); + }, []); + + const toggleEvent = (eventName: string) => { + setSelectedEvents((prev) => + prev.includes(eventName) ? prev.filter((item) => item !== eventName) : [...prev, eventName] + ); + }; + + const handleCreate = async (event: React.FormEvent) => { + event.preventDefault(); + setFormError(null); + + if (!url.trim()) { + setFormError(t('webhooks.errors.urlRequired')); + return; + } + if (secret.trim().length < 16) { + setFormError(t('webhooks.errors.secretTooShort')); + return; + } + + setIsSubmitting(true); + try { + const created = await createWebhookSubscription({ + url: url.trim(), + secret: secret.trim(), + events: selectedEvents.length > 0 ? selectedEvents : ['*'], + }); + setSubscriptions((prev) => [...prev, created]); + setUrl(''); + setSecret(''); + setSelectedEvents([]); + } catch (submitError) { + setFormError( + submitError instanceof Error ? submitError.message : t('webhooks.errors.createFailed') + ); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async (id: string) => { + setDeletingId(id); + setError(null); + try { + await deleteWebhookSubscription(id); + setSubscriptions((prev) => prev.filter((subscription) => subscription.id !== id)); + } catch (deleteError) { + setError( + deleteError instanceof Error ? deleteError.message : t('webhooks.errors.deleteFailed') + ); + } finally { + setDeletingId(null); + } + }; + + return ( +
+
+
+

{t('webhooks.title')}

+

{t('webhooks.subtitle')}

+
+
+ +
+

+ + {t('webhooks.createTitle')} +

+ +
+
+ + setUrl(event.target.value)} + placeholder={t('webhooks.urlPlaceholder')} + className="w-full bg-black/20 border border-hi rounded-xl p-4 text-text outline-none focus:border-accent/50 focus:bg-accent/5 transition-all" + /> +
+ +
+ +

{t('webhooks.secretDescription')}

+ setSecret(event.target.value)} + placeholder={t('webhooks.secretPlaceholder')} + className="w-full bg-black/20 border border-hi rounded-xl p-4 text-text outline-none focus:border-accent/50 focus:bg-accent/5 transition-all" + /> +
+ +
+ +

{t('webhooks.eventsDescription')}

+
+ {AVAILABLE_EVENTS.map((eventName) => { + const isSelected = selectedEvents.includes(eventName); + return ( + + ); + })} +
+
+ + {formError ?

{formError}

: null} + + +
+
+ +
+

+ + {t('webhooks.listTitle')} +

+ + {error ?

{error}

: null} + + {isLoading ? ( +
+ + {t('webhooks.loading')} +
+ ) : null} + + {!isLoading && subscriptions.length === 0 && !error ? ( +
+ +

{t('webhooks.empty')}

+
+ ) : null} + + {!isLoading && subscriptions.length > 0 ? ( +
    + {subscriptions.map((subscription) => ( +
  • +
    +

    {subscription.url}

    +
    + {subscription.events.map((eventName) => ( + + {eventName} + + ))} +
    +
    + +
  • + ))} +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/services/webhookApi.ts b/frontend/src/services/webhookApi.ts new file mode 100644 index 00000000..855fb484 --- /dev/null +++ b/frontend/src/services/webhookApi.ts @@ -0,0 +1,45 @@ +import axios from 'axios'; + +const RAW_API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000/api/v1'; +const API_ROOT = RAW_API_URL.replace(/\/api\/v1\/?$/, '').replace(/\/api\/?$/, ''); +const WEBHOOKS_URL = `${API_ROOT}/webhooks`; + +function authHeaders() { + const token = localStorage.getItem('payd_auth_token'); + return token ? { Authorization: `Bearer ${token}` } : undefined; +} + +export interface WebhookSubscription { + id: string; + url: string; + events: string[]; + organizationId: number; +} + +export interface CreateWebhookSubscriptionInput { + url: string; + secret: string; + events: string[]; +} + +export async function fetchWebhookSubscriptions(): Promise { + const { data } = await axios.get(`${WEBHOOKS_URL}/subscriptions`, { + headers: authHeaders(), + }); + return data; +} + +export async function createWebhookSubscription( + input: CreateWebhookSubscriptionInput +): Promise { + const { data } = await axios.post(`${WEBHOOKS_URL}/subscribe`, input, { + headers: authHeaders(), + }); + return data; +} + +export async function deleteWebhookSubscription(id: string): Promise { + await axios.delete(`${WEBHOOKS_URL}/subscriptions/${id}`, { + headers: authHeaders(), + }); +} From 64d457359f79d29c3b9fd625699542afbae2d2f0 Mon Sep 17 00:00:00 2001 From: LamsOfJos Date: Tue, 18 Aug 2026 20:50:35 +0100 Subject: [PATCH 2/2] fix: resolve lint failures in webhook subscription UI no-misused-promises flagged the async handleCreate passed directly to form onSubmit, and exhaustive-deps flagged loadSubscriptions being called from useEffect without being a listed dependency. Wrap the submit handler and move the loader inside the effect, matching the pattern already used elsewhere in the app (TransactionHistory, Forecasting). Verified npm run lint, prettier --check, and npm run build all pass locally. --- frontend/src/pages/WebhookSettings.tsx | 43 +++++++++++++++----------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/WebhookSettings.tsx b/frontend/src/pages/WebhookSettings.tsx index c6979c38..ece721a3 100644 --- a/frontend/src/pages/WebhookSettings.tsx +++ b/frontend/src/pages/WebhookSettings.tsx @@ -30,24 +30,22 @@ export default function WebhookSettings() { const [isSubmitting, setIsSubmitting] = useState(false); const [deletingId, setDeletingId] = useState(null); - const loadSubscriptions = async () => { - setIsLoading(true); - setError(null); - try { - const data = await fetchWebhookSubscriptions(); - setSubscriptions(data); - } catch (loadError) { - setError( - loadError instanceof Error ? loadError.message : t('webhooks.errors.loadFailed') - ); - } finally { - setIsLoading(false); - } - }; - useEffect(() => { + const loadSubscriptions = async () => { + setIsLoading(true); + setError(null); + try { + const data = await fetchWebhookSubscriptions(); + setSubscriptions(data); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : t('webhooks.errors.loadFailed')); + } finally { + setIsLoading(false); + } + }; + void loadSubscriptions(); - }, []); + }, [t]); const toggleEvent = (eventName: string) => { setSelectedEvents((prev) => @@ -112,7 +110,12 @@ export default function WebhookSettings() { -
+ { + void handleCreate(event); + }} + className="w-full card glass noise p-8 mb-8" + >

{t('webhooks.createTitle')} @@ -179,7 +182,11 @@ export default function WebhookSettings() { disabled={isSubmitting} className="self-start px-6 py-3 rounded-xl font-bold bg-accent text-black hover:opacity-90 transition-all disabled:opacity-50 flex items-center gap-2" > - {isSubmitting ? : } + {isSubmitting ? ( + + ) : ( + + )} {t('webhooks.createButton')}