diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index bd80828..1d38f38 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -173,3 +173,56 @@ export type DlqPage = { number: number size: number } + +// === Webhooks (v2) === + +export type WebhookSubscription = { + id: string + name: string + url: string + scopeFilter?: DataScope[] | null + dictionaryFilter?: string[] | null + eventTypeFilter?: string[] | null + /** Masked `sk_****` или plaintext только в response создания/rotate. */ + hmacSecret: string + active: boolean + createdAt: string + createdBy: string + updatedAt: string + description?: string | null +} + +export type CreateWebhookSubscriptionRequest = { + name: string + url: string + scopeFilter?: DataScope[] + dictionaryFilter?: string[] + eventTypeFilter?: string[] + active?: boolean + description?: string +} + +export type WebhookDeliveryStatus = 'pending' | 'retrying' | 'delivered' | 'dlq' + +export type WebhookDelivery = { + id: number + subscriptionId: string + outboxEventId: number + status: WebhookDeliveryStatus + attemptCount: number + createdAt: string + nextAttemptAt: string + lastAttemptAt?: string | null + deliveredAt?: string | null + lastError?: string | null + lastStatusCode?: number | null + dlqAt?: string | null +} + +export type WebhookDeliveryPage = { + content: WebhookDelivery[] + totalElements: number + totalPages: number + number: number + size: number +} diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 76b493c..e19cb30 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -3,8 +3,10 @@ import { apiClient, type CreateDictionaryRequest, type CreateRecordRequest, + type CreateWebhookSubscriptionRequest, type DictionaryDetail, type RecordResponse, + type WebhookSubscription, } from './client' const idempotencyKey = () => @@ -96,3 +98,69 @@ export const useCloseRecord = (dictionaryName: string) => { }, }) } + +// === Webhooks (v2) === + +export const useCreateWebhook = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (req: CreateWebhookSubscriptionRequest): Promise => { + const { data } = await apiClient.post( + '/admin/webhooks/subscriptions', + req, + ) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] }) + }, + }) +} + +export const useUpdateWebhook = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (params: { + id: string + payload: CreateWebhookSubscriptionRequest + }): Promise => { + const { data } = await apiClient.put( + `/admin/webhooks/subscriptions/${encodeURIComponent(params.id)}`, + params.payload, + ) + return data + }, + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] }) + qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions', vars.id] }) + }, + }) +} + +export const useDeleteWebhook = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (id: string): Promise => { + await apiClient.delete(`/admin/webhooks/subscriptions/${encodeURIComponent(id)}`) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] }) + }, + }) +} + +export const useRotateWebhookSecret = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (id: string): Promise => { + const { data } = await apiClient.post( + `/admin/webhooks/subscriptions/${encodeURIComponent(id)}/rotate-secret`, + ) + return data + }, + onSuccess: (_data, id) => { + qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] }) + qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions', id] }) + }, + }) +} diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index c988236..0203398 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -9,6 +9,8 @@ import { type FlattenedRecord, type OutboxStats, type RecordResponse, + type WebhookDeliveryPage, + type WebhookSubscription, } from './client' export const dictionariesQuery = queryOptions({ @@ -135,6 +137,70 @@ export const dlqQuery = (page: number, size: number) => export const useDlq = (page: number, size: number) => useQuery(dlqQuery(page, size)) +// === Webhooks (v2) === + +export const webhookSubscriptionsQuery = queryOptions({ + queryKey: ['webhooks', 'subscriptions'] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + '/admin/webhooks/subscriptions', + ) + return data + }, +}) + +export const useWebhookSubscriptions = () => useQuery(webhookSubscriptionsQuery) + +export const webhookSubscriptionQuery = (id: string) => + queryOptions({ + queryKey: ['webhooks', 'subscriptions', id] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/admin/webhooks/subscriptions/${encodeURIComponent(id)}`, + ) + return data + }, + }) + +export const useWebhookSubscription = (id: string) => + useQuery({ + ...webhookSubscriptionQuery(id), + enabled: Boolean(id), + }) + +export const webhookDeliveriesQuery = (id: string, page: number, size: number) => + queryOptions({ + queryKey: ['webhooks', 'deliveries', id, page, size] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/admin/webhooks/subscriptions/${encodeURIComponent(id)}/deliveries`, + { params: { page, size } }, + ) + return data + }, + }) + +export const useWebhookDeliveries = (id: string, page: number, size: number) => + useQuery({ + ...webhookDeliveriesQuery(id, page, size), + enabled: Boolean(id), + }) + +export const webhookDlqQuery = (page: number, size: number) => + queryOptions({ + queryKey: ['webhooks', 'dlq', page, size] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + '/admin/webhooks/deliveries/dlq', + { params: { page, size } }, + ) + return data + }, + }) + +export const useWebhookDlq = (page: number, size: number) => + useQuery(webhookDlqQuery(page, size)) + export const useDictionaries = () => useQuery(dictionariesQuery) export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name)) export const useRecords = (dictionaryName: string, scopeCsv: string) => diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index b682037..4b9d35e 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -16,6 +16,54 @@ i18n 'nav.dictionaries': 'Справочники', 'nav.audit': 'Аудит', 'nav.outbox': 'Outbox', + 'nav.webhooks': 'Webhooks', + 'webhooks.title': 'Webhook subscriptions', + 'webhooks.subtitle': 'HTTP-доставка событий не-Kafka подписчикам', + 'webhooks.empty': 'Нет подписок. Создайте первую через кнопку справа.', + 'webhooks.active': 'Активна', + 'webhooks.inactive': 'Отключена', + 'webhooks.allScopes': 'все scope', + 'webhooks.allDicts': 'все справочники', + 'webhooks.allEvents': 'все события', + 'webhooks.notFound': 'Подписка не найдена', + 'webhooks.col.name': 'Имя', + 'webhooks.col.url': 'URL', + 'webhooks.col.scopes': 'Scope filter', + 'webhooks.col.dictionaries': 'Справочники', + 'webhooks.col.status': 'Статус', + 'webhooks.action.create': 'Создать', + 'webhooks.action.delete': 'Удалить', + 'webhooks.action.rotateSecret': 'Сменить secret', + 'webhooks.confirmDelete': 'Удалить подписку «{{name}}»? Все pending deliveries удалятся каскадом.', + 'webhooks.confirmRotate': 'Сменить HMAC secret? Старый сразу перестанет работать — receiver надо обновить заранее.', + 'webhooks.field.name': 'Имя подписки', + 'webhooks.field.url': 'Webhook URL', + 'webhooks.field.urlHint': 'https:// или http://. Private CIDR (10/8, 192.168/16, 127/8) запрещены SSRF guard', + 'webhooks.field.scopeFilter': 'Scope filter', + 'webhooks.field.scopeFilterHint': 'Пусто = все scope', + 'webhooks.field.dictionaryFilter': 'Справочники (CSV)', + 'webhooks.field.dictionaryFilterHint': 'Через запятую: spacecraft, satellite_type. Пусто = все', + 'webhooks.field.eventTypeFilter': 'Типы событий (CSV)', + 'webhooks.field.eventTypeFilterHint': 'record.created, record.updated, record.closed. Пусто = все', + 'webhooks.field.description': 'Описание', + 'webhooks.field.createdBy': 'Создатель', + 'webhooks.error.nameUrlRequired': 'Имя и URL обязательны', + 'webhooks.error.urlScheme': 'URL должен начинаться с http:// или https://', + 'webhooks.secret.title': 'HMAC secret для «{{name}}»', + 'webhooks.secret.warningTitle': 'Скопируй сейчас!', + 'webhooks.secret.warning': 'Этот secret показывается ОДИН раз. Дальше будет замаскирован. Передай receiver чтобы он мог проверять X-Ordinis-Signature header.', + 'webhooks.secret.rotatedTitle': 'Secret обновлён', + 'webhooks.secret.rotatedWarning': 'Новый secret. Старый перестал работать. Обнови receiver.', + 'webhooks.secret.copy': 'Копировать', + 'webhooks.secret.copied': 'Скопировано', + 'webhooks.secret.acknowledge': 'Сохранил', + 'webhooks.deliveries.title': 'История доставок', + 'webhooks.deliveries.empty': 'Доставок ещё не было', + 'webhooks.deliveries.col.eventId': 'Event ID', + 'webhooks.deliveries.col.attempts': 'Попыток', + 'webhooks.deliveries.col.lastAttempt': 'Последняя попытка', + 'webhooks.deliveries.col.lastStatusCode': 'HTTP статус', + 'webhooks.deliveries.col.lastError': 'Последняя ошибка', 'audit.title': 'Журнал аудита', 'audit.subtitle': 'Юридически значимый лог изменений НСИ', 'audit.filter.dictionary': 'Справочник', @@ -192,6 +240,54 @@ i18n 'nav.dictionaries': 'Dictionaries', 'nav.audit': 'Audit', 'nav.outbox': 'Outbox', + 'nav.webhooks': 'Webhooks', + 'webhooks.title': 'Webhook subscriptions', + 'webhooks.subtitle': 'HTTP delivery of events to non-Kafka consumers', + 'webhooks.empty': 'No subscriptions yet. Create one via the button on the right.', + 'webhooks.active': 'Active', + 'webhooks.inactive': 'Disabled', + 'webhooks.allScopes': 'all scopes', + 'webhooks.allDicts': 'all dictionaries', + 'webhooks.allEvents': 'all events', + 'webhooks.notFound': 'Subscription not found', + 'webhooks.col.name': 'Name', + 'webhooks.col.url': 'URL', + 'webhooks.col.scopes': 'Scope filter', + 'webhooks.col.dictionaries': 'Dictionaries', + 'webhooks.col.status': 'Status', + 'webhooks.action.create': 'Create', + 'webhooks.action.delete': 'Delete', + 'webhooks.action.rotateSecret': 'Rotate secret', + 'webhooks.confirmDelete': 'Delete subscription "{{name}}"? All pending deliveries will be cascade-deleted.', + 'webhooks.confirmRotate': 'Rotate HMAC secret? The old one stops working immediately — update the receiver first.', + 'webhooks.field.name': 'Subscription name', + 'webhooks.field.url': 'Webhook URL', + 'webhooks.field.urlHint': 'https:// or http://. Private CIDRs (10/8, 192.168/16, 127/8) blocked by SSRF guard', + 'webhooks.field.scopeFilter': 'Scope filter', + 'webhooks.field.scopeFilterHint': 'Empty = all scopes', + 'webhooks.field.dictionaryFilter': 'Dictionaries (CSV)', + 'webhooks.field.dictionaryFilterHint': 'Comma-separated: spacecraft, satellite_type. Empty = all', + 'webhooks.field.eventTypeFilter': 'Event types (CSV)', + 'webhooks.field.eventTypeFilterHint': 'record.created, record.updated, record.closed. Empty = all', + 'webhooks.field.description': 'Description', + 'webhooks.field.createdBy': 'Created by', + 'webhooks.error.nameUrlRequired': 'Name and URL are required', + 'webhooks.error.urlScheme': 'URL must start with http:// or https://', + 'webhooks.secret.title': 'HMAC secret for "{{name}}"', + 'webhooks.secret.warningTitle': 'Copy now!', + 'webhooks.secret.warning': 'This secret is shown ONCE. It will be masked afterwards. Pass it to the receiver so it can verify the X-Ordinis-Signature header.', + 'webhooks.secret.rotatedTitle': 'Secret rotated', + 'webhooks.secret.rotatedWarning': 'New secret. Old one stopped working. Update the receiver.', + 'webhooks.secret.copy': 'Copy', + 'webhooks.secret.copied': 'Copied', + 'webhooks.secret.acknowledge': 'Saved it', + 'webhooks.deliveries.title': 'Delivery history', + 'webhooks.deliveries.empty': 'No deliveries yet', + 'webhooks.deliveries.col.eventId': 'Event ID', + 'webhooks.deliveries.col.attempts': 'Attempts', + 'webhooks.deliveries.col.lastAttempt': 'Last attempt', + 'webhooks.deliveries.col.lastStatusCode': 'HTTP status', + 'webhooks.deliveries.col.lastError': 'Last error', 'audit.title': 'Audit log', 'audit.subtitle': 'Legally significant log of NSI changes', 'audit.filter.dictionary': 'Dictionary', diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts index db2b665..bfecb6a 100644 --- a/ordinis-admin-ui/src/routeTree.gen.ts +++ b/ordinis-admin-ui/src/routeTree.gen.ts @@ -9,13 +9,21 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WebhooksRouteImport } from './routes/webhooks' import { Route as OutboxRouteImport } from './routes/outbox' import { Route as DictionariesRouteImport } from './routes/dictionaries' import { Route as AuditRouteImport } from './routes/audit' import { Route as IndexRouteImport } from './routes/index' +import { Route as WebhooksIndexRouteImport } from './routes/webhooks.index' import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index' +import { Route as WebhooksIdRouteImport } from './routes/webhooks.$id' import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name' +const WebhooksRoute = WebhooksRouteImport.update({ + id: '/webhooks', + path: '/webhooks', + getParentRoute: () => rootRouteImport, +} as any) const OutboxRoute = OutboxRouteImport.update({ id: '/outbox', path: '/outbox', @@ -36,11 +44,21 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const WebhooksIndexRoute = WebhooksIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => WebhooksRoute, +} as any) const DictionariesIndexRoute = DictionariesIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => DictionariesRoute, } as any) +const WebhooksIdRoute = WebhooksIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => WebhooksRoute, +} as any) const DictionariesNameRoute = DictionariesNameRouteImport.update({ id: '/$name', path: '/$name', @@ -52,15 +70,20 @@ export interface FileRoutesByFullPath { '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren '/outbox': typeof OutboxRoute + '/webhooks': typeof WebhooksRouteWithChildren '/dictionaries/$name': typeof DictionariesNameRoute + '/webhooks/$id': typeof WebhooksIdRoute '/dictionaries/': typeof DictionariesIndexRoute + '/webhooks/': typeof WebhooksIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/audit': typeof AuditRoute '/outbox': typeof OutboxRoute '/dictionaries/$name': typeof DictionariesNameRoute + '/webhooks/$id': typeof WebhooksIdRoute '/dictionaries': typeof DictionariesIndexRoute + '/webhooks': typeof WebhooksIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -68,8 +91,11 @@ export interface FileRoutesById { '/audit': typeof AuditRoute '/dictionaries': typeof DictionariesRouteWithChildren '/outbox': typeof OutboxRoute + '/webhooks': typeof WebhooksRouteWithChildren '/dictionaries/$name': typeof DictionariesNameRoute + '/webhooks/$id': typeof WebhooksIdRoute '/dictionaries/': typeof DictionariesIndexRoute + '/webhooks/': typeof WebhooksIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -78,18 +104,31 @@ export interface FileRouteTypes { | '/audit' | '/dictionaries' | '/outbox' + | '/webhooks' | '/dictionaries/$name' + | '/webhooks/$id' | '/dictionaries/' + | '/webhooks/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/audit' | '/outbox' | '/dictionaries/$name' | '/dictionaries' + to: + | '/' + | '/audit' + | '/outbox' + | '/dictionaries/$name' + | '/webhooks/$id' + | '/dictionaries' + | '/webhooks' id: | '__root__' | '/' | '/audit' | '/dictionaries' | '/outbox' + | '/webhooks' | '/dictionaries/$name' + | '/webhooks/$id' | '/dictionaries/' + | '/webhooks/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -97,10 +136,18 @@ export interface RootRouteChildren { AuditRoute: typeof AuditRoute DictionariesRoute: typeof DictionariesRouteWithChildren OutboxRoute: typeof OutboxRoute + WebhooksRoute: typeof WebhooksRouteWithChildren } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/webhooks': { + id: '/webhooks' + path: '/webhooks' + fullPath: '/webhooks' + preLoaderRoute: typeof WebhooksRouteImport + parentRoute: typeof rootRouteImport + } '/outbox': { id: '/outbox' path: '/outbox' @@ -129,6 +176,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/webhooks/': { + id: '/webhooks/' + path: '/' + fullPath: '/webhooks/' + preLoaderRoute: typeof WebhooksIndexRouteImport + parentRoute: typeof WebhooksRoute + } '/dictionaries/': { id: '/dictionaries/' path: '/' @@ -136,6 +190,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DictionariesIndexRouteImport parentRoute: typeof DictionariesRoute } + '/webhooks/$id': { + id: '/webhooks/$id' + path: '/$id' + fullPath: '/webhooks/$id' + preLoaderRoute: typeof WebhooksIdRouteImport + parentRoute: typeof WebhooksRoute + } '/dictionaries/$name': { id: '/dictionaries/$name' path: '/$name' @@ -160,11 +221,26 @@ const DictionariesRouteWithChildren = DictionariesRoute._addFileChildren( DictionariesRouteChildren, ) +interface WebhooksRouteChildren { + WebhooksIdRoute: typeof WebhooksIdRoute + WebhooksIndexRoute: typeof WebhooksIndexRoute +} + +const WebhooksRouteChildren: WebhooksRouteChildren = { + WebhooksIdRoute: WebhooksIdRoute, + WebhooksIndexRoute: WebhooksIndexRoute, +} + +const WebhooksRouteWithChildren = WebhooksRoute._addFileChildren( + WebhooksRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuditRoute: AuditRoute, DictionariesRoute: DictionariesRouteWithChildren, OutboxRoute: OutboxRoute, + WebhooksRoute: WebhooksRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index c922e41..4b4f698 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -44,6 +44,13 @@ function RootLayout() { > {t('nav.outbox')} + + {t('nav.webhooks')} +
= { + delivered: 'success', + retrying: 'warning', + pending: 'neutral', + dlq: 'error', +} + +function WebhookDetailPage() { + const { id } = Route.useParams() + const { t } = useTranslation() + const sub = useWebhookSubscription(id) + const [page, setPage] = useState(0) + const deliveries = useWebhookDeliveries(id, page, 50) + const rotateMut = useRotateWebhookSecret() + const deleteMut = useDeleteWebhook() + const [revealedSecret, setRevealedSecret] = useState(null) + + const handleRotate = () => { + if (!window.confirm(t('webhooks.confirmRotate'))) return + rotateMut.mutate(id, { + onSuccess: (s) => setRevealedSecret(s.hmacSecret), + }) + } + + const handleDelete = () => { + if (!sub.data) return + if (!window.confirm(t('webhooks.confirmDelete', { name: sub.data.name }))) return + deleteMut.mutate(id, { + onSuccess: () => { + window.location.assign('/webhooks') + }, + }) + } + + if (sub.isLoading) return + if (sub.error || !sub.data) { + return ( + + {String(sub.error ?? t('webhooks.notFound'))} + + ) + } + + const data = sub.data + + return ( +
+ + ← {t('nav.webhooks')} + + } + title={data.name} + description={data.description ?? data.url} + actions={ +
+ + +
+ } + /> + + +
+ + {data.url} + + + + {data.active ? t('webhooks.active') : t('webhooks.inactive')} + + + + {data.scopeFilter && data.scopeFilter.length > 0 ? ( +
+ {data.scopeFilter.map((s) => ( + + + {s} + + ))} +
+ ) : ( + {t('webhooks.allScopes')} + )} +
+ + + {data.dictionaryFilter && data.dictionaryFilter.length > 0 + ? data.dictionaryFilter.join(', ') + : t('webhooks.allDicts')} + + + + + {data.eventTypeFilter && data.eventTypeFilter.length > 0 + ? data.eventTypeFilter.join(', ') + : t('webhooks.allEvents')} + + + + {data.hmacSecret} + + + + {data.createdBy} · {new Date(data.createdAt).toLocaleString()} + + +
+
+ +
+
+

+ {t('webhooks.deliveries.title')} +

+ } + onClick={() => deliveries.refetch()} + /> +
+ + {deliveries.isLoading ? ( + + ) : !deliveries.data || deliveries.data.content.length === 0 ? ( + + ) : ( + + + + + {t('webhooks.deliveries.col.eventId')} + {t('webhooks.col.status')} + {t('webhooks.deliveries.col.attempts')} + {t('webhooks.deliveries.col.lastAttempt')} + {t('webhooks.deliveries.col.lastStatusCode')} + {t('webhooks.deliveries.col.lastError')} + + + + {deliveries.data.content.map((d) => ( + + + {d.outboxEventId} + + + + {d.status} + + + + {d.attemptCount} + + + + {d.lastAttemptAt + ? new Date(d.lastAttemptAt).toLocaleString() + : '—'} + + + + + {d.lastStatusCode ?? '—'} + + + + + {d.lastError ?? '—'} + + + + ))} + +
+
+ )} + + {deliveries.data && (deliveries.data.totalPages ?? 0) > 1 && ( +
+ + + {page + 1} / {deliveries.data.totalPages} + + +
+ )} +
+ + {revealedSecret && ( + setRevealedSecret(null)} + /> + )} +
+ ) +} + +function InfoRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ) +} + +function RotateRevealModal({ + secret, + onClose, +}: { + secret: string + onClose: () => void +}) { + const { t } = useTranslation() + return ( +
+
e.stopPropagation()} + > +

+ {t('webhooks.secret.rotatedTitle')} +

+ + {t('webhooks.secret.rotatedWarning')} + +
+ {secret} +
+
+ +
+
+
+ ) +} diff --git a/ordinis-admin-ui/src/routes/webhooks.index.tsx b/ordinis-admin-ui/src/routes/webhooks.index.tsx new file mode 100644 index 0000000..b527810 --- /dev/null +++ b/ordinis-admin-ui/src/routes/webhooks.index.tsx @@ -0,0 +1,388 @@ +import { useState } from 'react' +import { createFileRoute, Link } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { + Alert, + Badge, + Button, + EmptyState, + IconButton, + LoadingBlock, + Modal, + MultiSelect, + PageHeader, + Panel, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + TextArea, + TextInput, + type SelectOption, +} from '@nstart/ui' +import { + CopyIcon, + PlusIcon, + TrashIcon, +} from '@phosphor-icons/react' +import { useWebhookSubscriptions } from '@/api/queries' +import { + useCreateWebhook, + useDeleteWebhook, +} from '@/api/mutations' +import type { + CreateWebhookSubscriptionRequest, + DataScope, + WebhookSubscription, +} from '@/api/client' +import { SCOPE_DOT } from '@/lib/scope-style' + +export const Route = createFileRoute('/webhooks/')({ + component: WebhooksPage, +}) + +const SCOPE_OPTIONS: SelectOption[] = [ + { id: 'PUBLIC', label: 'PUBLIC' }, + { id: 'INTERNAL', label: 'INTERNAL' }, + { id: 'RESTRICTED', label: 'RESTRICTED' }, +] + +function WebhooksPage() { + const { t } = useTranslation() + const { data, isLoading, error } = useWebhookSubscriptions() + const [createOpen, setCreateOpen] = useState(false) + const [revealedSecret, setRevealedSecret] = useState<{ + name: string + secret: string + } | null>(null) + const deleteMut = useDeleteWebhook() + + const handleDelete = (sub: WebhookSubscription) => { + if (!window.confirm(t('webhooks.confirmDelete', { name: sub.name }))) return + deleteMut.mutate(sub.id) + } + + if (isLoading) return + if (error) { + return ( + + {String(error)} + + ) + } + + return ( +
+ } + onClick={() => setCreateOpen(true)} + > + {t('webhooks.action.create')} + + } + /> + + {!data || data.length === 0 ? ( + + ) : ( + + + + + {t('webhooks.col.name')} + {t('webhooks.col.url')} + {t('webhooks.col.scopes')} + {t('webhooks.col.dictionaries')} + {t('webhooks.col.status')} + {t('dict.col.actions')} + + + + {data.map((sub) => ( + + + + {sub.name} + + + + + {sub.url} + + + + + + + {sub.dictionaryFilter && sub.dictionaryFilter.length > 0 ? ( + + {sub.dictionaryFilter.slice(0, 3).join(', ')} + {sub.dictionaryFilter.length > 3 && ` +${sub.dictionaryFilter.length - 3}`} + + ) : ( + {t('webhooks.allDicts')} + )} + + + + {sub.active ? t('webhooks.active') : t('webhooks.inactive')} + + + + } + onClick={() => handleDelete(sub)} + /> + + + ))} + +
+
+ )} + + setCreateOpen(false)} + onSuccess={(sub, secret) => { + setCreateOpen(false) + setRevealedSecret({ name: sub.name, secret }) + }} + /> + + setRevealedSecret(null)} + /> +
+ ) +} + +function ScopeChips({ scopes }: { scopes?: DataScope[] | null }) { + const { t } = useTranslation() + if (!scopes || scopes.length === 0) { + return {t('webhooks.allScopes')} + } + return ( +
+ {scopes.map((s) => ( + + + + ))} +
+ ) +} + +type CreateDialogProps = { + open: boolean + onClose: () => void + onSuccess: (sub: WebhookSubscription, secret: string) => void +} + +function CreateWebhookDialog({ open, onClose, onSuccess }: CreateDialogProps) { + const { t } = useTranslation() + const create = useCreateWebhook() + + const [name, setName] = useState('') + const [url, setUrl] = useState('') + const [scopeFilter, setScopeFilter] = useState([]) + const [dictionaryFilterCsv, setDictionaryFilterCsv] = useState('') + const [eventTypeFilterCsv, setEventTypeFilterCsv] = useState('') + const [description, setDescription] = useState('') + const [errorMsg, setErrorMsg] = useState(null) + + const reset = () => { + setName('') + setUrl('') + setScopeFilter([]) + setDictionaryFilterCsv('') + setEventTypeFilterCsv('') + setDescription('') + setErrorMsg(null) + } + + const csvToArray = (s: string): string[] | undefined => { + const arr = s + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + return arr.length > 0 ? arr : undefined + } + + const handleSubmit = () => { + setErrorMsg(null) + if (!name.trim() || !url.trim()) { + setErrorMsg(t('webhooks.error.nameUrlRequired')) + return + } + if (!/^https?:\/\//.test(url)) { + setErrorMsg(t('webhooks.error.urlScheme')) + return + } + const req: CreateWebhookSubscriptionRequest = { + name: name.trim(), + url: url.trim(), + scopeFilter: scopeFilter.length > 0 ? (scopeFilter as DataScope[]) : undefined, + dictionaryFilter: csvToArray(dictionaryFilterCsv), + eventTypeFilter: csvToArray(eventTypeFilterCsv), + active: true, + description: description.trim() || undefined, + } + create.mutate(req, { + onSuccess: (sub) => { + const plaintext = sub.hmacSecret + reset() + onSuccess(sub, plaintext) + }, + onError: (e) => setErrorMsg(String(e)), + }) + } + + return ( + { + reset() + onClose() + }} + title={t('webhooks.action.create')} + maxWidth="max-w-2xl" + > +
+ setName(e.target.value)} + /> + setUrl(e.target.value)} + /> + setScopeFilter(ids as string[])} + hint={t('webhooks.field.scopeFilterHint')} + /> + setDictionaryFilterCsv(e.target.value)} + /> + setEventTypeFilterCsv(e.target.value)} + /> +