portal-mojo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react';
|
|
2
|
+
import { modal } from '../../ui';
|
|
3
|
+
|
|
4
|
+
async function copyText(value: string): Promise<boolean> {
|
|
5
|
+
try {
|
|
6
|
+
if (navigator.clipboard?.writeText) {
|
|
7
|
+
await navigator.clipboard.writeText(value);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
} catch {
|
|
11
|
+
// Fall through for insecure contexts.
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const input = document.createElement('textarea');
|
|
15
|
+
input.value = value;
|
|
16
|
+
input.style.position = 'fixed';
|
|
17
|
+
input.style.opacity = '0';
|
|
18
|
+
document.body.appendChild(input);
|
|
19
|
+
input.select();
|
|
20
|
+
const copied = document.execCommand('copy');
|
|
21
|
+
input.remove();
|
|
22
|
+
return copied;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SecretBox({ secret, ariaLabel }: { secret: string; ariaLabel: string }) {
|
|
29
|
+
const [copied, setCopied] = useState(false);
|
|
30
|
+
return (
|
|
31
|
+
<div className="ga-secret-box">
|
|
32
|
+
<code className="ga-secret-value" aria-label={ariaLabel}>{secret}</code>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
className="btn btn-compact"
|
|
36
|
+
title="Copy to clipboard"
|
|
37
|
+
aria-label="Copy to clipboard"
|
|
38
|
+
onClick={() => {
|
|
39
|
+
void copyText(secret).then((ok) => {
|
|
40
|
+
if (!ok) return;
|
|
41
|
+
setCopied(true);
|
|
42
|
+
setTimeout(() => setCopied(false), 1600);
|
|
43
|
+
});
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<i className={`bi ${copied ? 'bi-check-lg' : 'bi-clipboard'}`} /> {copied ? 'Copied' : 'Copy'}
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SecretDialogOptions {
|
|
53
|
+
title: string;
|
|
54
|
+
warning: ReactNode;
|
|
55
|
+
intro?: ReactNode;
|
|
56
|
+
secret: string;
|
|
57
|
+
ariaLabel: string;
|
|
58
|
+
footer?: ReactNode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function showSecretDialog(options: SecretDialogOptions): Promise<unknown> {
|
|
62
|
+
return modal.open((close) => (
|
|
63
|
+
<div className="modal-pad">
|
|
64
|
+
<h2 className="modal-title">{options.title}</h2>
|
|
65
|
+
{options.intro && <div className="ga-secret-intro">{options.intro}</div>}
|
|
66
|
+
<div className="ga-secret-warning" role="alert">
|
|
67
|
+
<i className="bi bi-exclamation-triangle-fill" />
|
|
68
|
+
<span>{options.warning}</span>
|
|
69
|
+
</div>
|
|
70
|
+
<SecretBox secret={options.secret} ariaLabel={options.ariaLabel} />
|
|
71
|
+
{options.footer}
|
|
72
|
+
<div className="modal-actions">
|
|
73
|
+
<button className="btn" onClick={() => close(null)}>Close</button>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
), { size: 'lg' });
|
|
77
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useCan, type PermSpec } from '../../client';
|
|
4
|
+
import {
|
|
5
|
+
ArmedButton, Badge, DetailView, Eyebrow, FlatRow, ModelTable,
|
|
6
|
+
SchemaForm, fmt, modal, toast,
|
|
7
|
+
type Column, type Field, type FilterDef,
|
|
8
|
+
} from '../../ui';
|
|
9
|
+
import {
|
|
10
|
+
GLOBAL_CREDENTIAL_PERMS, GROUP_CREDENTIAL_PERMS,
|
|
11
|
+
WebhookSubscriptionModel, fetchWebhookSecret, normalizeWebhookEvents,
|
|
12
|
+
type CredentialGroup, type WebhookSecretInfo, type WebhookSubscriptionRow,
|
|
13
|
+
} from './models';
|
|
14
|
+
import { showSecretDialog } from './secret-dialog';
|
|
15
|
+
|
|
16
|
+
function groupId(group: WebhookSubscriptionRow['group']): number | null {
|
|
17
|
+
if (typeof group === 'number') return group;
|
|
18
|
+
return group?.id ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function groupLabel(group: WebhookSubscriptionRow['group']): string {
|
|
22
|
+
if (group && typeof group === 'object') return group.name;
|
|
23
|
+
return group == null ? '—' : `Group #${group}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isoRelative(value: string | null): string {
|
|
27
|
+
if (!value) return '—';
|
|
28
|
+
const milliseconds = Date.parse(value);
|
|
29
|
+
return Number.isNaN(milliseconds) ? value : fmt.relative(Math.floor(milliseconds / 1000));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function WebhookSecretPanel({ group, permission = GROUP_CREDENTIAL_PERMS }: {
|
|
33
|
+
group: CredentialGroup;
|
|
34
|
+
permission?: PermSpec;
|
|
35
|
+
}) {
|
|
36
|
+
const { can } = useCan(permission);
|
|
37
|
+
const [metadata, setMetadata] = useState<Omit<WebhookSecretInfo, 'secret'> | null>(null);
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
|
|
40
|
+
const reveal = async () => {
|
|
41
|
+
if (!can) return;
|
|
42
|
+
setBusy(true);
|
|
43
|
+
try {
|
|
44
|
+
const info = await fetchWebhookSecret(group.id);
|
|
45
|
+
setMetadata({ created_at: info.created_at, last_rotated_at: info.last_rotated_at });
|
|
46
|
+
await showSecretDialog({
|
|
47
|
+
title: 'Webhook signing secret',
|
|
48
|
+
warning: 'This explicit reveal may mint the first secret. Store it securely.',
|
|
49
|
+
secret: info.secret,
|
|
50
|
+
ariaLabel: 'Webhook signing secret',
|
|
51
|
+
footer: <div className="ga-secret-footnote dim">Consumers use this value to verify webhook signatures.</div>,
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
toast.error(error instanceof Error ? error.message : 'Failed to reveal webhook secret');
|
|
55
|
+
} finally {
|
|
56
|
+
setBusy(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const rotate = async () => {
|
|
61
|
+
if (!can) return;
|
|
62
|
+
const confirmed = await modal.confirm({
|
|
63
|
+
title: 'Rotate webhook secret',
|
|
64
|
+
message: 'Rotation immediately invalidates the old secret. Consumers fail verification until updated. Continue?',
|
|
65
|
+
confirmText: 'Rotate',
|
|
66
|
+
danger: true,
|
|
67
|
+
});
|
|
68
|
+
if (!confirmed) return;
|
|
69
|
+
setBusy(true);
|
|
70
|
+
try {
|
|
71
|
+
const info = await fetchWebhookSecret(group.id, true);
|
|
72
|
+
setMetadata({ created_at: info.created_at, last_rotated_at: info.last_rotated_at });
|
|
73
|
+
await showSecretDialog({
|
|
74
|
+
title: 'Secret rotated — save the new value',
|
|
75
|
+
warning: 'The old secret is already invalid. Store this replacement now.',
|
|
76
|
+
secret: info.secret,
|
|
77
|
+
ariaLabel: 'Webhook signing secret',
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
toast.error(error instanceof Error ? error.message : 'Failed to rotate webhook secret');
|
|
81
|
+
} finally {
|
|
82
|
+
setBusy(false);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="ga-secret-panel">
|
|
88
|
+
<div className="ga-secret-panel-info">
|
|
89
|
+
<div className="ga-secret-panel-title"><i className="bi bi-key" /> Signing secret</div>
|
|
90
|
+
<div className="dim">
|
|
91
|
+
{metadata
|
|
92
|
+
? <>Created <b>{isoRelative(metadata.created_at)}</b> · Last rotated <b>{isoRelative(metadata.last_rotated_at)}</b></>
|
|
93
|
+
: 'Not fetched — Reveal may mint the first group secret.'}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
{can && (
|
|
97
|
+
<div className="ga-secret-panel-actions">
|
|
98
|
+
<button className="btn btn-compact" disabled={busy} onClick={() => void reveal()}>
|
|
99
|
+
<i className="bi bi-eye" /> Reveal Secret
|
|
100
|
+
</button>
|
|
101
|
+
<button className="btn btn-compact btn-danger-ghost" disabled={busy} onClick={() => void rotate()}>
|
|
102
|
+
<i className="bi bi-arrow-clockwise" /> Rotate
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const WEBHOOK_FIELDS: Field[] = [
|
|
111
|
+
{
|
|
112
|
+
name: 'url', type: 'text', label: 'URL', required: true,
|
|
113
|
+
placeholder: 'https://example.com/webhooks/mojo',
|
|
114
|
+
help: 'HTTPS only. Embedded credentials are rejected server-side.',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'events', type: 'tags', label: 'Events',
|
|
118
|
+
placeholder: 'Press Enter or comma to add',
|
|
119
|
+
help: 'Free-form event names published by the emitting service.',
|
|
120
|
+
},
|
|
121
|
+
{ name: 'is_active', type: 'switch', label: 'Active', help: 'Inactive subscriptions are skipped.' },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function useWebhookActions(permission: PermSpec) {
|
|
125
|
+
const save = WebhookSubscriptionModel.useSave();
|
|
126
|
+
const destroy = WebhookSubscriptionModel.useDelete();
|
|
127
|
+
const { can } = useCan(permission);
|
|
128
|
+
|
|
129
|
+
const createSubscription = async (fixedGroup?: CredentialGroup) => {
|
|
130
|
+
if (!can) return;
|
|
131
|
+
const result = await modal.open<Record<string, unknown> | null>((close) => (
|
|
132
|
+
<div className="modal-pad">
|
|
133
|
+
<h2 className="modal-title">Create webhook subscription</h2>
|
|
134
|
+
<SchemaForm
|
|
135
|
+
fields={[
|
|
136
|
+
...WEBHOOK_FIELDS,
|
|
137
|
+
...(!fixedGroup ? [{
|
|
138
|
+
name: 'group', type: 'collection', label: 'Group', required: true,
|
|
139
|
+
endpoint: '/api/group', labelField: 'name', valueField: 'id',
|
|
140
|
+
placeholder: 'Search groups…',
|
|
141
|
+
} satisfies Field] : []),
|
|
142
|
+
]}
|
|
143
|
+
initial={{ is_active: true }}
|
|
144
|
+
submitText="Create subscription"
|
|
145
|
+
onCancel={() => close(null)}
|
|
146
|
+
onSubmit={(form) => close(form)}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
), { size: 'md' });
|
|
150
|
+
if (!result) return;
|
|
151
|
+
const group = fixedGroup?.id ?? Number(result.group);
|
|
152
|
+
if (!Number.isFinite(group) || group <= 0) {
|
|
153
|
+
toast.error('Choose a group');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
await save.mutateAsync({
|
|
158
|
+
id: null,
|
|
159
|
+
changes: {
|
|
160
|
+
group,
|
|
161
|
+
url: String(result.url ?? ''),
|
|
162
|
+
events: normalizeWebhookEvents(result.events),
|
|
163
|
+
is_active: result.is_active === true,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
toast.success('Webhook subscription created');
|
|
167
|
+
} catch (error) {
|
|
168
|
+
toast.error(error instanceof Error ? error.message : 'Failed to create webhook subscription');
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const editSubscription = async (row: WebhookSubscriptionRow) => {
|
|
173
|
+
if (!can) return;
|
|
174
|
+
const result = await modal.open<Record<string, unknown> | null>((close) => (
|
|
175
|
+
<div className="modal-pad">
|
|
176
|
+
<h2 className="modal-title">Edit webhook subscription</h2>
|
|
177
|
+
<SchemaForm
|
|
178
|
+
fields={WEBHOOK_FIELDS}
|
|
179
|
+
initial={{ url: row.url, events: row.events.join(','), is_active: row.is_active }}
|
|
180
|
+
submitText="Save changes"
|
|
181
|
+
onCancel={() => close(null)}
|
|
182
|
+
onSubmit={(form) => close(form)}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
), { size: 'md' });
|
|
186
|
+
if (!result) return;
|
|
187
|
+
const changes: Record<string, unknown> = {};
|
|
188
|
+
const url = String(result.url ?? '');
|
|
189
|
+
const events = normalizeWebhookEvents(result.events);
|
|
190
|
+
const isActive = result.is_active === true;
|
|
191
|
+
if (url !== row.url) changes.url = url;
|
|
192
|
+
if (events.join('\u0000') !== row.events.join('\u0000')) changes.events = events;
|
|
193
|
+
if (isActive !== row.is_active) changes.is_active = isActive;
|
|
194
|
+
if (!Object.keys(changes).length) return;
|
|
195
|
+
try {
|
|
196
|
+
await save.mutateAsync({ id: row.id, changes });
|
|
197
|
+
toast.success('Webhook subscription updated');
|
|
198
|
+
} catch (error) {
|
|
199
|
+
toast.error(error instanceof Error ? error.message : 'Failed to update webhook subscription');
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const toggleSubscription = async (row: WebhookSubscriptionRow, next: boolean) => {
|
|
204
|
+
if (!can) return;
|
|
205
|
+
try {
|
|
206
|
+
await save.mutateAsync({ id: row.id, changes: { is_active: next } });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
toast.error(error instanceof Error ? error.message : 'Failed to update subscription');
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const deleteSubscription = async (row: WebhookSubscriptionRow) => {
|
|
213
|
+
if (!can) return false;
|
|
214
|
+
try {
|
|
215
|
+
await destroy.mutateAsync({ id: row.id });
|
|
216
|
+
toast.success('Webhook subscription deleted');
|
|
217
|
+
return true;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
toast.error(error instanceof Error ? error.message : 'Failed to delete subscription');
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return { canManage: can, createSubscription, editSubscription, toggleSubscription, deleteSubscription };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function WebhookCard({ row, actions }: {
|
|
228
|
+
row: WebhookSubscriptionRow;
|
|
229
|
+
actions: ReturnType<typeof useWebhookActions>;
|
|
230
|
+
}) {
|
|
231
|
+
const [toggling, setToggling] = useState(false);
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
className={`ga-card-row${actions.canManage ? ' ga-click-row' : ''}`}
|
|
235
|
+
role={actions.canManage ? 'button' : undefined}
|
|
236
|
+
tabIndex={actions.canManage ? 0 : undefined}
|
|
237
|
+
onClick={actions.canManage ? () => void actions.editSubscription(row) : undefined}
|
|
238
|
+
onKeyDown={actions.canManage ? (event) => {
|
|
239
|
+
if (event.key === 'Enter') {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
void actions.editSubscription(row);
|
|
242
|
+
}
|
|
243
|
+
} : undefined}
|
|
244
|
+
>
|
|
245
|
+
<i className="bi bi-broadcast-pin ga-card-icon" />
|
|
246
|
+
<div className="ga-card-main">
|
|
247
|
+
<div className="ga-card-title">
|
|
248
|
+
<code className="ga-url">{row.url || '—'}</code>
|
|
249
|
+
<Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge>
|
|
250
|
+
</div>
|
|
251
|
+
<div className="chip-row">
|
|
252
|
+
{row.events.length
|
|
253
|
+
? row.events.map((event) => <Badge key={event} tone="info">{event}</Badge>)
|
|
254
|
+
: <span className="dim-italic">No events configured</span>}
|
|
255
|
+
</div>
|
|
256
|
+
<div className="dim ga-card-meta">Created {fmt.datetime(row.created)}</div>
|
|
257
|
+
</div>
|
|
258
|
+
{actions.canManage && (
|
|
259
|
+
<div className="ga-card-actions" onClick={(event) => event.stopPropagation()}>
|
|
260
|
+
<label className="switch-inline" title="Toggle delivery">
|
|
261
|
+
<input
|
|
262
|
+
type="checkbox"
|
|
263
|
+
role="switch"
|
|
264
|
+
className="switch"
|
|
265
|
+
checked={row.is_active}
|
|
266
|
+
disabled={toggling}
|
|
267
|
+
onChange={(event) => {
|
|
268
|
+
setToggling(true);
|
|
269
|
+
void actions.toggleSubscription(row, event.target.checked)
|
|
270
|
+
.finally(() => setToggling(false));
|
|
271
|
+
}}
|
|
272
|
+
/>
|
|
273
|
+
</label>
|
|
274
|
+
<ArmedButton
|
|
275
|
+
className="btn-compact"
|
|
276
|
+
label={<i className="bi bi-trash" aria-label="Delete this subscription" />}
|
|
277
|
+
armedLabel="Click again — deliveries stop"
|
|
278
|
+
title="Delete this subscription"
|
|
279
|
+
onConfirm={async () => { await actions.deleteSubscription(row); }}
|
|
280
|
+
/>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function WebhookSubscriptionsContent({ group, permission }: { group: CredentialGroup; permission: PermSpec }) {
|
|
288
|
+
const { data, isPending } = WebhookSubscriptionModel.useList({ group: group.id, size: 25, sort: '-created' });
|
|
289
|
+
const actions = useWebhookActions(permission);
|
|
290
|
+
const rows = data?.rows ?? [];
|
|
291
|
+
return (
|
|
292
|
+
<>
|
|
293
|
+
<Eyebrow>Webhooks</Eyebrow>
|
|
294
|
+
<WebhookSecretPanel group={group} permission={permission} />
|
|
295
|
+
<div className="ga-section-gap" />
|
|
296
|
+
<Eyebrow>Subscriptions</Eyebrow>
|
|
297
|
+
{actions.canManage && (
|
|
298
|
+
<div className="ga-toolbar">
|
|
299
|
+
<button className="btn btn-primary btn-compact" onClick={() => void actions.createSubscription(group)}>
|
|
300
|
+
<i className="bi bi-broadcast" /> Create Subscription
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
{!isPending && rows.length === 0 && <p className="dim-italic">No webhook subscriptions yet.</p>}
|
|
305
|
+
{rows.map((row) => <WebhookCard key={row.id} row={row} actions={actions} />)}
|
|
306
|
+
</>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function WebhookSubscriptionsSection({ group, permission = GROUP_CREDENTIAL_PERMS }: {
|
|
311
|
+
group: CredentialGroup;
|
|
312
|
+
permission?: PermSpec;
|
|
313
|
+
}) {
|
|
314
|
+
const { can } = useCan(permission);
|
|
315
|
+
if (!can) return <p className="dim-italic">Webhooks are unavailable for this account.</p>;
|
|
316
|
+
return <WebhookSubscriptionsContent group={group} permission={permission} />;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function WebhookSubscriptionDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
320
|
+
const { data: row, isPending, error } = WebhookSubscriptionModel.useOne(id);
|
|
321
|
+
const actions = useWebhookActions(GLOBAL_CREDENTIAL_PERMS);
|
|
322
|
+
if (isPending) return <div className="modal-pad dim">Loading webhook subscription…</div>;
|
|
323
|
+
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'Subscription not found'}</div>;
|
|
324
|
+
const idForGroup = groupId(row.group);
|
|
325
|
+
const group = idForGroup == null ? null : {
|
|
326
|
+
id: idForGroup,
|
|
327
|
+
name: groupLabel(row.group),
|
|
328
|
+
...(typeof row.group === 'object' && row.group?.kind ? { kind: row.group.kind } : {}),
|
|
329
|
+
};
|
|
330
|
+
return (
|
|
331
|
+
<DetailView
|
|
332
|
+
icon="bi-broadcast-pin"
|
|
333
|
+
title={row.url}
|
|
334
|
+
subtitle={`${groupLabel(row.group)} · webhook subscription`}
|
|
335
|
+
chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }]}
|
|
336
|
+
sections={[
|
|
337
|
+
{
|
|
338
|
+
key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
339
|
+
<>
|
|
340
|
+
<Eyebrow>Subscription</Eyebrow>
|
|
341
|
+
<FlatRow label="Group">{groupLabel(row.group)}</FlatRow>
|
|
342
|
+
<FlatRow label="URL"><code>{row.url}</code></FlatRow>
|
|
343
|
+
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
344
|
+
<FlatRow label="Modified">{fmt.datetime(row.modified)}</FlatRow>
|
|
345
|
+
<div className="ga-toolbar" style={{ marginTop: 16 }}>
|
|
346
|
+
<button className="btn btn-compact" onClick={() => void actions.editSubscription(row)}>
|
|
347
|
+
<i className="bi bi-pencil" /> Edit URL, events, and status
|
|
348
|
+
</button>
|
|
349
|
+
<ArmedButton
|
|
350
|
+
className="btn-compact"
|
|
351
|
+
label="Delete"
|
|
352
|
+
armedLabel="Click again — delete now"
|
|
353
|
+
onConfirm={async () => {
|
|
354
|
+
if (await actions.deleteSubscription(row)) onClose();
|
|
355
|
+
}}
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
</>
|
|
359
|
+
),
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
key: 'events', label: 'Events', icon: 'bi-tags', render: () => (
|
|
363
|
+
<div className="chip-row">
|
|
364
|
+
{row.events.length
|
|
365
|
+
? row.events.map((event) => <Badge key={event} tone="info">{event}</Badge>)
|
|
366
|
+
: <span className="dim-italic">No events configured</span>}
|
|
367
|
+
</div>
|
|
368
|
+
),
|
|
369
|
+
},
|
|
370
|
+
...(group ? [{
|
|
371
|
+
key: 'secret', label: 'Signing Secret', icon: 'bi-key',
|
|
372
|
+
render: () => <WebhookSecretPanel group={group} permission={GLOBAL_CREDENTIAL_PERMS} />,
|
|
373
|
+
}] : []),
|
|
374
|
+
]}
|
|
375
|
+
onClose={onClose}
|
|
376
|
+
/>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const WEBHOOK_COLUMNS: Column<WebhookSubscriptionRow>[] = [
|
|
381
|
+
{ key: 'url', label: 'URL', sortable: true, hideable: false, render: (row) => <code className="ga-url">{row.url}</code> },
|
|
382
|
+
{ key: 'group', label: 'Group', render: (row) => groupLabel(row.group) },
|
|
383
|
+
{ key: 'events', label: 'Events', render: (row) => row.events.slice(0, 3).map((event) => <Badge key={event} tone="info">{event}</Badge>) },
|
|
384
|
+
{ key: 'is_active', label: 'Status', render: (row) => <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge> },
|
|
385
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
|
|
386
|
+
];
|
|
387
|
+
|
|
388
|
+
const WEBHOOK_FILTERS: FilterDef[] = [
|
|
389
|
+
{ key: 'url', label: 'URL', type: 'text', placeholder: 'Contains…' },
|
|
390
|
+
{ key: 'group', label: 'Group ID', type: 'number', lookup: 'exact' },
|
|
391
|
+
{ key: 'is_active', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
|
|
392
|
+
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
export function WebhookSubscriptionsPage() {
|
|
396
|
+
const queryClient = useQueryClient();
|
|
397
|
+
const actions = useWebhookActions(GLOBAL_CREDENTIAL_PERMS);
|
|
398
|
+
const openDetail = (row: WebhookSubscriptionRow) => {
|
|
399
|
+
void WebhookSubscriptionModel.fetchOne(queryClient, row.id).catch(() => undefined);
|
|
400
|
+
void modal.detail((close) => <WebhookSubscriptionDetail id={row.id} onClose={() => close(null)} />);
|
|
401
|
+
};
|
|
402
|
+
return (
|
|
403
|
+
<ModelTable<WebhookSubscriptionRow>
|
|
404
|
+
model={WebhookSubscriptionModel}
|
|
405
|
+
eyebrow="Account"
|
|
406
|
+
title="Webhook Subscriptions"
|
|
407
|
+
searchable={false}
|
|
408
|
+
columns={WEBHOOK_COLUMNS}
|
|
409
|
+
filters={WEBHOOK_FILTERS}
|
|
410
|
+
presets={[
|
|
411
|
+
{ key: 'all', label: 'All', params: {} },
|
|
412
|
+
{ key: 'active', label: 'Active', params: { is_active: 'true' } },
|
|
413
|
+
{ key: 'inactive', label: 'Inactive', params: { is_active: 'false' } },
|
|
414
|
+
]}
|
|
415
|
+
defaultSort="-created"
|
|
416
|
+
columnChooser
|
|
417
|
+
persistState
|
|
418
|
+
onRowClick={openDetail}
|
|
419
|
+
addLabel="New Subscription"
|
|
420
|
+
onAdd={actions.canManage ? () => void actions.createSubscription() : undefined}
|
|
421
|
+
/>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Link } from 'react-router-dom';
|
|
2
|
+
import { MetricsChart } from '../../charts';
|
|
3
|
+
import { modal } from '../../ui';
|
|
4
|
+
import { useAuthSnapshot, useCan } from '../../client';
|
|
5
|
+
import { UserDetail } from '../identity';
|
|
6
|
+
import { SECURITY_VIEW_PERMS } from '../security-permissions';
|
|
7
|
+
import { JOBS_VIEW_PERMS } from '../jobs';
|
|
8
|
+
import { EMAIL_ADMIN_PERMISSIONS } from '../messaging';
|
|
9
|
+
import {
|
|
10
|
+
LOGIN_SUMMARY_PERMS, LoginLocationMap, showLoginEventDetail,
|
|
11
|
+
showUserDeviceDetailByDuid,
|
|
12
|
+
} from '../security/devices';
|
|
13
|
+
import {
|
|
14
|
+
DASHBOARD_METRIC_PERMISSIONS, DASHBOARD_SERIES, dashboardLoginStart,
|
|
15
|
+
useDashboardCount, useDashboardScalars,
|
|
16
|
+
} from './data';
|
|
17
|
+
|
|
18
|
+
export const ADMIN_DASHBOARD_PERMISSIONS = [...new Set([
|
|
19
|
+
...DASHBOARD_METRIC_PERMISSIONS,
|
|
20
|
+
...SECURITY_VIEW_PERMS,
|
|
21
|
+
...JOBS_VIEW_PERMS,
|
|
22
|
+
...EMAIL_ADMIN_PERMISSIONS,
|
|
23
|
+
...LOGIN_SUMMARY_PERMS,
|
|
24
|
+
])];
|
|
25
|
+
|
|
26
|
+
function MetricOverview() {
|
|
27
|
+
const { can } = useCan(DASHBOARD_METRIC_PERMISSIONS);
|
|
28
|
+
const auth = useAuthSnapshot();
|
|
29
|
+
const scalars = useDashboardScalars(can);
|
|
30
|
+
if (!can) return null;
|
|
31
|
+
return <>
|
|
32
|
+
<div className="admin-dashboard-kpis">
|
|
33
|
+
{(['total_users', 'total_groups'] as const).map((slug) => (
|
|
34
|
+
<div className="panel panel-pad admin-dashboard-kpi" key={slug}>
|
|
35
|
+
<div className="eyebrow">Directory</div>
|
|
36
|
+
<div className="admin-dashboard-kpi-value">{scalars.isPending ? '…' : scalars.data?.[slug].toLocaleString() ?? '—'}</div>
|
|
37
|
+
<div className="dim">{slug === 'total_users' ? 'Total users' : 'Total groups'}</div>
|
|
38
|
+
</div>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
{scalars.error && <div className="panel panel-pad text-bad">{scalars.error.message}</div>}
|
|
42
|
+
<MetricsChart
|
|
43
|
+
title="Platform activity"
|
|
44
|
+
account="global"
|
|
45
|
+
slugs={[...DASHBOARD_SERIES]}
|
|
46
|
+
seriesLabels={{ user_activity_day: 'User activity', group_activity_day: 'Group activity', api_calls: 'API calls', api_errors: 'API errors' }}
|
|
47
|
+
seriesCacheKey={`admin-dashboard:${auth.uid ?? 'anonymous'}:activity`}
|
|
48
|
+
defaultRange="30d"
|
|
49
|
+
defaultGranularity="days"
|
|
50
|
+
height={300}
|
|
51
|
+
/>
|
|
52
|
+
</>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function AttentionCard({ permission, endpoint, params, to, icon, label }: {
|
|
56
|
+
permission: string[]; endpoint: string; params: Record<string, string>; to: string; icon: string; label: string;
|
|
57
|
+
}) {
|
|
58
|
+
const { can } = useCan(permission);
|
|
59
|
+
const count = useDashboardCount(endpoint, params, can);
|
|
60
|
+
if (!can) return null;
|
|
61
|
+
return <Link className="panel panel-pad admin-dashboard-attention" to={to}>
|
|
62
|
+
<i className={`bi ${icon}`} />
|
|
63
|
+
<span><strong>{count.isPending ? '…' : count.data?.toLocaleString() ?? '—'}</strong><small>{label}</small></span>
|
|
64
|
+
<i className="bi bi-arrow-right" />
|
|
65
|
+
</Link>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function AttentionOverview() {
|
|
69
|
+
return <section>
|
|
70
|
+
<div className="admin-dashboard-section-heading"><div><div className="eyebrow">Attention</div><h2>Needs review</h2></div></div>
|
|
71
|
+
<div className="admin-dashboard-attention-grid">
|
|
72
|
+
<AttentionCard permission={SECURITY_VIEW_PERMS} endpoint="/api/incident/incident" params={{ status: 'open' }} to="/security/incidents?status=open" icon="bi-shield-exclamation" label="Open incidents" />
|
|
73
|
+
<AttentionCard permission={JOBS_VIEW_PERMS} endpoint="/api/jobs/job" params={{ status: 'failed' }} to="/jobs/list?status=failed" icon="bi-cpu" label="Failed jobs" />
|
|
74
|
+
<AttentionCard permission={EMAIL_ADMIN_PERMISSIONS} endpoint="/api/aws/email/sent" params={{ status: 'bounced' }} to="/email/sent?status=bounced" icon="bi-envelope-exclamation" label="Bounced messages" />
|
|
75
|
+
</div>
|
|
76
|
+
</section>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function LoginOverview() {
|
|
80
|
+
const { can } = useCan(LOGIN_SUMMARY_PERMS);
|
|
81
|
+
if (!can) return null;
|
|
82
|
+
const openUser = (id: number) => { void modal.detail((close) => <UserDetail id={id} onClose={() => close(null)} />); };
|
|
83
|
+
return <section className="panel panel-pad dashboard-login-map">
|
|
84
|
+
<div><div className="eyebrow">Security</div><h2>Login locations · last 30 days</h2></div>
|
|
85
|
+
<LoginLocationMap
|
|
86
|
+
height={360}
|
|
87
|
+
drStart={dashboardLoginStart()}
|
|
88
|
+
onOpenUser={openUser}
|
|
89
|
+
onOpenLogin={(id) => showLoginEventDetail(id, { onOpenUser: openUser, onOpenDeviceByDuid: showUserDeviceDetailByDuid })}
|
|
90
|
+
/>
|
|
91
|
+
</section>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function AdminDashboardPage() {
|
|
95
|
+
return <div className="admin-dashboard">
|
|
96
|
+
<header className="admin-dashboard-header"><div className="eyebrow">Overview</div><h1>Admin dashboard</h1><p className="dim">Authoritative operational signals for this django-mojo deployment.</p></header>
|
|
97
|
+
<MetricOverview />
|
|
98
|
+
<AttentionOverview />
|
|
99
|
+
<LoginOverview />
|
|
100
|
+
</div>;
|
|
101
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { mojoCall, mojoList, useAuthSnapshot, type Params } from '../../client';
|
|
3
|
+
|
|
4
|
+
export const DASHBOARD_METRIC_PERMISSIONS = ['sys.view_metrics', 'sys.metrics'];
|
|
5
|
+
export const DASHBOARD_SERIES = ['user_activity_day', 'group_activity_day', 'api_calls', 'api_errors'] as const;
|
|
6
|
+
export const DASHBOARD_SCALARS = ['total_users', 'total_groups'] as const;
|
|
7
|
+
|
|
8
|
+
export type DashboardScalar = typeof DASHBOARD_SCALARS[number];
|
|
9
|
+
export type DashboardScalars = Record<DashboardScalar, number>;
|
|
10
|
+
|
|
11
|
+
export function parseDashboardScalar(value: unknown, slug: string): number {
|
|
12
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
13
|
+
throw new Error(`Metric ${slug} did not return a scalar string or number.`);
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === 'string' && value.trim() === '') {
|
|
16
|
+
throw new Error(`Metric ${slug} returned an empty value.`);
|
|
17
|
+
}
|
|
18
|
+
const parsed = Number(value);
|
|
19
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
20
|
+
throw new Error(`Metric ${slug} did not return a finite non-negative value.`);
|
|
21
|
+
}
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function fetchDashboardScalars(): Promise<DashboardScalars> {
|
|
26
|
+
const body = await mojoCall('/api/metrics/value/get', {
|
|
27
|
+
params: { account: 'global', slugs: DASHBOARD_SCALARS.join(',') },
|
|
28
|
+
});
|
|
29
|
+
const data = body.data;
|
|
30
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
31
|
+
throw new Error('Dashboard scalar metrics returned a malformed payload.');
|
|
32
|
+
}
|
|
33
|
+
const values = data as Record<string, unknown>;
|
|
34
|
+
return {
|
|
35
|
+
total_users: parseDashboardScalar(values.total_users, 'total_users'),
|
|
36
|
+
total_groups: parseDashboardScalar(values.total_groups, 'total_groups'),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useDashboardScalars(enabled = true) {
|
|
41
|
+
const auth = useAuthSnapshot();
|
|
42
|
+
return useQuery({
|
|
43
|
+
queryKey: ['admin-dashboard', 'scalars', auth.uid],
|
|
44
|
+
queryFn: fetchDashboardScalars,
|
|
45
|
+
enabled: enabled && auth.authenticated && Boolean(auth.uid),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useDashboardCount(endpoint: string, params: Params, enabled = true) {
|
|
50
|
+
const auth = useAuthSnapshot();
|
|
51
|
+
return useQuery({
|
|
52
|
+
queryKey: ['admin-dashboard', 'count', auth.uid, endpoint, params],
|
|
53
|
+
queryFn: async () => (await mojoList(endpoint, { ...params, start: 0, size: 0 })).count,
|
|
54
|
+
enabled: enabled && auth.authenticated && Boolean(auth.uid),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function dashboardLoginStart(now = Date.now()): string {
|
|
59
|
+
return new Date(now - 30 * 86_400_000).toISOString().slice(0, 10);
|
|
60
|
+
}
|