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,804 @@
|
|
|
1
|
+
// GeoIpDossier — the GeoIPView port (web-mojo 1255 lines, read in full),
|
|
2
|
+
// presented as a KISS `modal.detail` (#1425). `DetailView` already IS a rail
|
|
3
|
+
// plus a body, so the size is handled by seven rail entries, one tabbed
|
|
4
|
+
// Activity section and a collapsed raw reveal — not by a new presentation.
|
|
5
|
+
//
|
|
6
|
+
// Owned for the wave: #1287 imports `GeoIpDossier`, `showGeoIpDossier` and
|
|
7
|
+
// `showGeoIpDossierForAddress` rather than building its own IP inspector.
|
|
8
|
+
import { useState, type ReactNode } from 'react';
|
|
9
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
10
|
+
import {
|
|
11
|
+
ArmedButton, Badge, DetailView, Eyebrow, FlatRow, KnownFieldsCard, StatusPanel,
|
|
12
|
+
MetricCard, formModal, fmt, modal, toast,
|
|
13
|
+
type Chip, type Tone,
|
|
14
|
+
} from '../../../ui';
|
|
15
|
+
import { WorldMap, useWorldLand } from '../../../charts';
|
|
16
|
+
import { useCan } from '../../../client';
|
|
17
|
+
import { EventModel } from '../../incidents/models';
|
|
18
|
+
import { SECURITY_VIEW_PERMS } from '../../security-permissions';
|
|
19
|
+
import { LogModel, LOGS_ADMIN_PERMISSIONS } from '../../monitoring/models';
|
|
20
|
+
import {
|
|
21
|
+
DEVICE_LOCATION_VIEW_PERMS, LOGIN_EVENT_VIEW_PERMS,
|
|
22
|
+
LoginEventModel, UserDeviceLocationModel,
|
|
23
|
+
} from '../devices/models';
|
|
24
|
+
import {
|
|
25
|
+
GEOIP_BLOCK_FIELDS, GEOIP_EDIT_LOCATION_FIELDS, GEOIP_EDIT_NETWORK_FIELDS,
|
|
26
|
+
GEOIP_EDIT_SECURITY_FIELDS, GEOIP_UNBLOCK_FIELDS, GEOIP_WHITELIST_FIELDS,
|
|
27
|
+
} from './geoip-forms';
|
|
28
|
+
import {
|
|
29
|
+
GEOIP_MANAGE_PERMS, GeoLocatedIPModel,
|
|
30
|
+
blockActive, blockExpired, countryFlag, threatTone, useGeoIpByAddress,
|
|
31
|
+
useGeoIpRawRecord, whitelistActive, whitelistExpired,
|
|
32
|
+
type GeoLocatedIPRow,
|
|
33
|
+
} from './models';
|
|
34
|
+
|
|
35
|
+
// ── Small shared bits ─────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const DASH = <span className="dim-italic">—</span>;
|
|
38
|
+
|
|
39
|
+
function text(value: string | number | null | undefined): ReactNode {
|
|
40
|
+
return value == null || value === '' ? DASH : value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function YesNo({ value }: { value: boolean | null | undefined }) {
|
|
44
|
+
return value
|
|
45
|
+
? <span className="text-bad"><i className="bi bi-check-circle-fill" /> Yes</span>
|
|
46
|
+
: <span className="dim"><i className="bi bi-dash-circle" /> No</span>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function CountryLabel({ row }: { row: GeoLocatedIPRow }) {
|
|
50
|
+
const flag = countryFlag(row.country_code);
|
|
51
|
+
if (!row.country_code && !row.country_name) return DASH;
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
{flag && <span className="geoip-flag">{flag}</span>}
|
|
55
|
+
{row.country_name || row.country_code}
|
|
56
|
+
{row.country_code && <code className="dim geoip-cc">{row.country_code}</code>}
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The header's synthetic subtitle (`_refreshComputedFields`), minus the
|
|
62
|
+
* `reverse ${reverse_dns}` fragment — that field does not exist. */
|
|
63
|
+
function subtitleOf(row: GeoLocatedIPRow): string {
|
|
64
|
+
const parts: string[] = [];
|
|
65
|
+
const place = [row.city, row.region, row.country_name].filter(Boolean).join(', ');
|
|
66
|
+
if (place) parts.push(place);
|
|
67
|
+
if (row.asn || row.isp) {
|
|
68
|
+
const asn = row.asn ? `ASN ${row.asn}` : '';
|
|
69
|
+
const isp = row.isp ? ` (${row.isp})` : '';
|
|
70
|
+
parts.push(`${asn}${isp}`.trim());
|
|
71
|
+
}
|
|
72
|
+
if (row.subnet) parts.push(`subnet ${row.subnet}`);
|
|
73
|
+
return parts.join(' · ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** GeoIPView's `iconToneFn`, with expiry-aware block state. */
|
|
77
|
+
function iconTone(row: GeoLocatedIPRow): Tone {
|
|
78
|
+
const level = String(row.threat_level ?? '').toLowerCase();
|
|
79
|
+
if (blockActive(row) || row.is_threat || ['high', 'critical'].includes(level)) return 'danger';
|
|
80
|
+
if (row.is_suspicious || level === 'medium') return 'warning';
|
|
81
|
+
return 'info';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `_statusTone` / `_statusState` / `_statusHeadline` / `_statusMeta`, ported
|
|
85
|
+
* with `blockActive`/`whitelistActive` replacing the raw booleans. */
|
|
86
|
+
function statusOf(row: GeoLocatedIPRow): { tone: Tone; state: string; headline: string; meta: ReactNode } {
|
|
87
|
+
const level = String(row.threat_level ?? '').toLowerCase();
|
|
88
|
+
const blocked = blockActive(row);
|
|
89
|
+
const whitelisted = whitelistActive(row);
|
|
90
|
+
|
|
91
|
+
if (blocked) {
|
|
92
|
+
return {
|
|
93
|
+
tone: 'danger',
|
|
94
|
+
state: 'Blocked',
|
|
95
|
+
headline: row.blocked_reason ? `Blocked: ${row.blocked_reason}` : 'Currently blocked',
|
|
96
|
+
meta: (
|
|
97
|
+
<>
|
|
98
|
+
Blocked {row.blocked_until != null
|
|
99
|
+
? <>until <strong>{fmt.datetime(row.blocked_until)}</strong></>
|
|
100
|
+
: 'permanently'}
|
|
101
|
+
{row.blocked_at != null && <> · {fmt.relative(row.blocked_at)}</>}
|
|
102
|
+
</>
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (whitelisted) {
|
|
107
|
+
return {
|
|
108
|
+
tone: 'success',
|
|
109
|
+
state: 'Whitelisted',
|
|
110
|
+
headline: row.whitelisted_reason ? `Whitelisted: ${row.whitelisted_reason}` : 'On whitelist',
|
|
111
|
+
meta: row.whitelisted_until != null
|
|
112
|
+
? <>This IP bypasses the firewall until <strong>{fmt.datetime(row.whitelisted_until)}</strong></>
|
|
113
|
+
: 'This IP bypasses the firewall',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const scoreMeta: ReactNode = (
|
|
118
|
+
<>
|
|
119
|
+
Risk score <strong>{row.risk_score ?? '—'}</strong>
|
|
120
|
+
{row.last_seen != null && <> · last seen {fmt.relative(row.last_seen)}</>}
|
|
121
|
+
{blockExpired(row) && <> · <span className="text-warn">previous block expired</span></>}
|
|
122
|
+
{whitelistExpired(row) && <> · <span className="text-warn">whitelist expired</span></>}
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (row.is_threat || ['high', 'critical'].includes(level)) {
|
|
127
|
+
return { tone: 'danger', state: 'Allowed · high risk', headline: `Active threat (${level || 'high'})`, meta: scoreMeta };
|
|
128
|
+
}
|
|
129
|
+
if (row.is_suspicious || level === 'medium') {
|
|
130
|
+
const flags: string[] = [];
|
|
131
|
+
if (row.is_vpn) flags.push('VPN');
|
|
132
|
+
if (row.is_tor) flags.push('Tor');
|
|
133
|
+
if (row.is_proxy) flags.push('proxy');
|
|
134
|
+
if (row.is_datacenter) flags.push('datacenter');
|
|
135
|
+
return {
|
|
136
|
+
tone: 'warning',
|
|
137
|
+
state: 'Allowed · elevated risk',
|
|
138
|
+
headline: flags.length ? `${flags.join(' / ')} signal detected` : `Suspicious${level ? ` · ${level}` : ''}`,
|
|
139
|
+
meta: scoreMeta,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { tone: 'success', state: 'Allowed', headline: 'No active threat signals', meta: scoreMeta };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `firedFlags` from GeoIPView.js:519-538 — descriptions carried verbatim. */
|
|
146
|
+
const REPUTATION_FLAGS: readonly { key: keyof GeoLocatedIPRow; label: string; icon: string; tone: Tone; title: string; detail: string }[] = [
|
|
147
|
+
{ key: 'is_threat', label: 'Threat', icon: 'bi-shield-exclamation', tone: 'danger', title: 'Active threat', detail: 'Marked as an active threat' },
|
|
148
|
+
{ key: 'is_suspicious', label: 'Suspicious', icon: 'bi-question-octagon', tone: 'warning', title: 'Suspicious', detail: 'Flagged suspicious by enrichment' },
|
|
149
|
+
{ key: 'is_known_attacker', label: 'Attacker', icon: 'bi-exclamation-octagon-fill', tone: 'danger', title: 'Known attacker', detail: 'Recorded in attacker feeds' },
|
|
150
|
+
{ key: 'is_known_abuser', label: 'Abuser', icon: 'bi-exclamation-triangle-fill', tone: 'danger', title: 'Known abuser', detail: 'Recorded in abuse feeds' },
|
|
151
|
+
{ key: 'is_vpn', label: 'VPN', icon: 'bi-shield-shaded', tone: 'warning', title: 'VPN exit', detail: 'Detected as a VPN exit node' },
|
|
152
|
+
{ key: 'is_tor', label: 'Tor', icon: 'bi-shield-lock', tone: 'danger', title: 'Tor exit', detail: 'Detected as a Tor exit node' },
|
|
153
|
+
{ key: 'is_proxy', label: 'Proxy', icon: 'bi-diagram-3', tone: 'warning', title: 'Open proxy', detail: 'Detected as an open proxy' },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
function chipsOf(row: GeoLocatedIPRow): Chip[] {
|
|
157
|
+
const chips: Chip[] = [];
|
|
158
|
+
const flag = countryFlag(row.country_code);
|
|
159
|
+
if (row.country_code || row.country_name) {
|
|
160
|
+
chips.push({ text: `${flag ? `${flag} ` : ''}${row.country_name || row.country_code}`.trim() });
|
|
161
|
+
}
|
|
162
|
+
const level = String(row.threat_level ?? '').toLowerCase();
|
|
163
|
+
if (level) {
|
|
164
|
+
chips.push({
|
|
165
|
+
icon: threatTone(level) === 'danger' ? 'bi-exclamation-triangle-fill' : 'bi-shield-check',
|
|
166
|
+
text: `Threat: ${row.threat_level}`,
|
|
167
|
+
tone: threatTone(level) as Tone,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (row.risk_score != null) chips.push({ text: `Risk score ${row.risk_score}` });
|
|
171
|
+
if (row.is_vpn) chips.push({ icon: 'bi-shield-shaded', text: 'VPN', tone: 'warning' });
|
|
172
|
+
if (row.is_tor) chips.push({ icon: 'bi-shield-lock', text: 'Tor', tone: 'danger' });
|
|
173
|
+
if (row.is_proxy) chips.push({ icon: 'bi-diagram-3', text: 'Proxy', tone: 'warning' });
|
|
174
|
+
if (row.is_cloud) chips.push({ icon: 'bi-cloud-fill', text: 'Cloud', tone: 'info' });
|
|
175
|
+
if (row.is_datacenter) chips.push({ icon: 'bi-hdd-stack', text: 'Datacenter', tone: 'warning' });
|
|
176
|
+
if (row.is_mobile) chips.push({ icon: 'bi-phone', text: 'Mobile', tone: 'info' });
|
|
177
|
+
if (blockActive(row)) chips.push({ icon: 'bi-slash-circle', text: 'Blocked', tone: 'danger' });
|
|
178
|
+
if (whitelistActive(row)) chips.push({ icon: 'bi-shield-check', text: 'Whitelisted', tone: 'success' });
|
|
179
|
+
return chips;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Activity tabs ─────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
function LocalTable({ headers, children, empty, pending, error }: {
|
|
185
|
+
headers: string[];
|
|
186
|
+
children: ReactNode;
|
|
187
|
+
empty: boolean;
|
|
188
|
+
pending: boolean;
|
|
189
|
+
error: Error | null;
|
|
190
|
+
}) {
|
|
191
|
+
if (pending) return <p className="dim">Loading…</p>;
|
|
192
|
+
if (error) return <p className="text-bad">{error.message}</p>;
|
|
193
|
+
if (empty) return <p className="dim-italic">Nothing recorded for this IP.</p>;
|
|
194
|
+
return (
|
|
195
|
+
<div className="geoip-local-table">
|
|
196
|
+
<table className="tbl">
|
|
197
|
+
<thead><tr>{headers.map((h) => <th key={h}>{h}</th>)}</tr></thead>
|
|
198
|
+
<tbody>{children}</tbody>
|
|
199
|
+
</table>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function EventsTab({ ip }: { ip: string }) {
|
|
205
|
+
const query = EventModel.useList({ source_ip: ip, size: 25, sort: '-created' });
|
|
206
|
+
const rows = query.data?.rows ?? [];
|
|
207
|
+
return (
|
|
208
|
+
<LocalTable headers={['Date', 'Level', 'Category', 'Details']} empty={!rows.length} pending={query.isPending} error={query.error}>
|
|
209
|
+
{rows.map((row) => (
|
|
210
|
+
<tr key={row.id}>
|
|
211
|
+
<td>{fmt.datetime(row.created)}</td>
|
|
212
|
+
<td><Badge tone={row.level >= 8 ? 'danger' : row.level >= 4 ? 'warning' : 'muted'}>L{row.level}</Badge></td>
|
|
213
|
+
<td><code>{row.category}</code></td>
|
|
214
|
+
<td>{fmt.truncate(row.title || row.details || '—', 70)}</td>
|
|
215
|
+
</tr>
|
|
216
|
+
))}
|
|
217
|
+
</LocalTable>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function LogsTab({ ip }: { ip: string }) {
|
|
222
|
+
const query = LogModel.useList({ ip, size: 25, sort: '-created' });
|
|
223
|
+
const rows = query.data?.rows ?? [];
|
|
224
|
+
return (
|
|
225
|
+
<LocalTable headers={['Timestamp', 'Level', 'Kind', 'Log']} empty={!rows.length} pending={query.isPending} error={query.error}>
|
|
226
|
+
{rows.map((row) => (
|
|
227
|
+
<tr key={row.id}>
|
|
228
|
+
<td>{fmt.datetime(row.created)}</td>
|
|
229
|
+
<td>{row.level}</td>
|
|
230
|
+
<td>{row.kind || '—'}</td>
|
|
231
|
+
<td>{fmt.truncate(row.log || row.path || '—', 70)}</td>
|
|
232
|
+
</tr>
|
|
233
|
+
))}
|
|
234
|
+
</LocalTable>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** NEW vs the source, and half of "who else used this IP". */
|
|
239
|
+
function LoginsTab({ ip, onOpenLogin }: { ip: string; onOpenLogin?: (id: number) => void }) {
|
|
240
|
+
const query = LoginEventModel.useList({ ip_address: ip, size: 25, sort: '-created', graph: 'list' });
|
|
241
|
+
const rows = query.data?.rows ?? [];
|
|
242
|
+
return (
|
|
243
|
+
<LocalTable headers={['When', 'User', 'Where', 'Source', 'New']} empty={!rows.length} pending={query.isPending} error={query.error}>
|
|
244
|
+
{rows.map((row) => (
|
|
245
|
+
<tr key={row.id} className={onOpenLogin ? 'row-click' : undefined} onClick={onOpenLogin ? () => onOpenLogin(row.id) : undefined}>
|
|
246
|
+
<td>{fmt.datetime(row.created)}</td>
|
|
247
|
+
<td>{row.user?.display_name || row.user?.username || '—'}</td>
|
|
248
|
+
<td>{[row.city, row.region].filter(Boolean).join(', ') || '—'}</td>
|
|
249
|
+
<td>{row.source || '—'}</td>
|
|
250
|
+
<td>{row.is_new_country ? <Badge tone="danger">Country</Badge> : row.is_new_region ? <Badge tone="warning">Region</Badge> : '—'}</td>
|
|
251
|
+
</tr>
|
|
252
|
+
))}
|
|
253
|
+
</LocalTable>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The other half — which devices have been seen on this address. */
|
|
258
|
+
function DevicesTab({ ip, onOpenLocation }: { ip: string; onOpenLocation?: (id: number) => void }) {
|
|
259
|
+
const query = UserDeviceLocationModel.useList({ ip_address: ip, size: 25, sort: '-last_seen' });
|
|
260
|
+
const rows = query.data?.rows ?? [];
|
|
261
|
+
return (
|
|
262
|
+
<LocalTable headers={['Owner', 'Device', 'First seen', 'Last seen']} empty={!rows.length} pending={query.isPending} error={query.error}>
|
|
263
|
+
{rows.map((row) => (
|
|
264
|
+
<tr key={row.id} className={onOpenLocation ? 'row-click' : undefined} onClick={onOpenLocation ? () => onOpenLocation(row.id) : undefined}>
|
|
265
|
+
<td>{row.user?.display_name || row.user?.username || '—'}</td>
|
|
266
|
+
<td><code title={row.user_device?.duid}>{fmt.truncateMiddle(row.user_device?.duid ?? '—', 16)}</code></td>
|
|
267
|
+
<td>{fmt.relative(row.first_seen)}</td>
|
|
268
|
+
<td>{fmt.relative(row.last_seen)}</td>
|
|
269
|
+
</tr>
|
|
270
|
+
))}
|
|
271
|
+
</LocalTable>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Each tab carries its OWN backend gate — the section is not gated as a
|
|
277
|
+
* whole, because the four tabs answer to four different endpoints. An
|
|
278
|
+
* operator holding only `sys.users` can read the Logins and Devices tabs but
|
|
279
|
+
* not Events or Logs, and must see exactly that rather than nothing.
|
|
280
|
+
*/
|
|
281
|
+
function ActivitySection({ ip, canEvents, canLogs, canLogins, canDevices, onOpenLogin, onOpenLocation }: {
|
|
282
|
+
ip: string;
|
|
283
|
+
canEvents: boolean;
|
|
284
|
+
canLogs: boolean;
|
|
285
|
+
canLogins: boolean;
|
|
286
|
+
canDevices: boolean;
|
|
287
|
+
onOpenLogin?: (id: number) => void;
|
|
288
|
+
onOpenLocation?: (id: number) => void;
|
|
289
|
+
}) {
|
|
290
|
+
const tabs = [
|
|
291
|
+
...(canEvents ? [{ key: 'events', label: 'Events', render: () => <EventsTab ip={ip} /> }] : []),
|
|
292
|
+
...(canLogs ? [{ key: 'logs', label: 'Logs', render: () => <LogsTab ip={ip} /> }] : []),
|
|
293
|
+
...(canLogins ? [{ key: 'logins', label: 'Logins', render: () => <LoginsTab ip={ip} onOpenLogin={onOpenLogin} /> }] : []),
|
|
294
|
+
...(canDevices ? [{ key: 'devices', label: 'Devices', render: () => <DevicesTab ip={ip} onOpenLocation={onOpenLocation} /> }] : []),
|
|
295
|
+
];
|
|
296
|
+
const [tab, setTab] = useState(tabs[0]?.key ?? '');
|
|
297
|
+
const active = tabs.find((t) => t.key === tab) ?? tabs[0];
|
|
298
|
+
if (!active) {
|
|
299
|
+
return (
|
|
300
|
+
<>
|
|
301
|
+
<Eyebrow>Activity</Eyebrow>
|
|
302
|
+
<p className="dim-italic">
|
|
303
|
+
None of the activity feeds for this IP are readable with your grants.
|
|
304
|
+
</p>
|
|
305
|
+
</>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return (
|
|
309
|
+
<>
|
|
310
|
+
<Eyebrow>Activity</Eyebrow>
|
|
311
|
+
<div className="us-tabs" role="tablist">
|
|
312
|
+
{tabs.map((t) => (
|
|
313
|
+
<button
|
|
314
|
+
key={t.key}
|
|
315
|
+
type="button"
|
|
316
|
+
role="tab"
|
|
317
|
+
aria-selected={t.key === active.key}
|
|
318
|
+
className={`us-tab${t.key === active.key ? ' us-tab-active' : ''}`}
|
|
319
|
+
onClick={() => setTab(t.key)}
|
|
320
|
+
>
|
|
321
|
+
{t.label}
|
|
322
|
+
</button>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
{active.render()}
|
|
326
|
+
</>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Raw provider record (manage-gated, never cached) ──────────────────
|
|
331
|
+
|
|
332
|
+
function RawRecordReveal({ id, canManage }: { id: number; canManage: boolean }) {
|
|
333
|
+
const [open, setOpen] = useState(false);
|
|
334
|
+
const { data, isPending, error } = useGeoIpRawRecord(id, canManage && open);
|
|
335
|
+
if (!canManage) {
|
|
336
|
+
return <p className="dim-italic">The raw provider record requires a security-management grant.</p>;
|
|
337
|
+
}
|
|
338
|
+
if (!open) {
|
|
339
|
+
return (
|
|
340
|
+
<button className="btn btn-compact" onClick={() => setOpen(true)}>
|
|
341
|
+
<i className="bi bi-braces" /> Show raw provider record
|
|
342
|
+
</button>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return (
|
|
346
|
+
<div className="geoip-raw">
|
|
347
|
+
<div className="geoip-raw-head">
|
|
348
|
+
<span className="dim">Provider: <code>{data?.provider || 'unknown'}</code></span>
|
|
349
|
+
<button className="btn btn-compact" onClick={() => setOpen(false)}>Hide</button>
|
|
350
|
+
</div>
|
|
351
|
+
{isPending && <p className="dim">Loading…</p>}
|
|
352
|
+
{error && <p className="text-bad">{error.message}</p>}
|
|
353
|
+
{data && <pre className="geoip-raw-body">{data.text}</pre>}
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── The dossier ───────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
export interface GeoIpDossierProps {
|
|
361
|
+
id: number;
|
|
362
|
+
onClose: () => void;
|
|
363
|
+
/** Cross-link seams for the consuming surface. */
|
|
364
|
+
onOpenLogin?: (id: number) => void;
|
|
365
|
+
onOpenLocation?: (id: number) => void;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function GeoIpDossier({ id, onClose, onOpenLogin, onOpenLocation }: GeoIpDossierProps) {
|
|
369
|
+
const qc = useQueryClient();
|
|
370
|
+
const { data: row, isPending, error } = GeoLocatedIPModel.useOne(id);
|
|
371
|
+
const canManage = useCan(GEOIP_MANAGE_PERMS).can;
|
|
372
|
+
const canEvents = useCan(SECURITY_VIEW_PERMS).can;
|
|
373
|
+
const canLogs = useCan(LOGS_ADMIN_PERMISSIONS).can;
|
|
374
|
+
const canLogins = useCan(LOGIN_EVENT_VIEW_PERMS).can;
|
|
375
|
+
const canDevices = useCan(DEVICE_LOCATION_VIEW_PERMS).can;
|
|
376
|
+
|
|
377
|
+
const save = GeoLocatedIPModel.useSave();
|
|
378
|
+
const blockAction = GeoLocatedIPModel.useAction('block');
|
|
379
|
+
const unblockAction = GeoLocatedIPModel.useAction('unblock');
|
|
380
|
+
const whitelistAction = GeoLocatedIPModel.useAction('whitelist');
|
|
381
|
+
const unwhitelistAction = GeoLocatedIPModel.useAction('unwhitelist');
|
|
382
|
+
const refreshAction = GeoLocatedIPModel.useAction('refresh');
|
|
383
|
+
const threatAction = GeoLocatedIPModel.useAction('threat_analysis');
|
|
384
|
+
|
|
385
|
+
// Count KPIs replacing the four phantom fields (`event_count`,
|
|
386
|
+
// `incident_count`, `login_attempts`, `login_count` exist on no model and
|
|
387
|
+
// rendered "—" forever). size:0 returns the count with no rows.
|
|
388
|
+
const ip = row?.ip_address ?? '';
|
|
389
|
+
const eventCount = EventModel.useList({ source_ip: ip, size: 0 }, { enabled: Boolean(ip) && canEvents }).data?.count;
|
|
390
|
+
const loginCount = LoginEventModel.useList({ ip_address: ip, size: 0 }, { enabled: Boolean(ip) && canLogins }).data?.count;
|
|
391
|
+
const deviceCount = UserDeviceLocationModel.useList({ ip_address: ip, size: 0 }, { enabled: Boolean(ip) && canDevices }).data?.count;
|
|
392
|
+
|
|
393
|
+
// Basemap for the Overview mini-map, in its own lazily-fetched chunk. Must
|
|
394
|
+
// sit above the early returns below — it is a hook.
|
|
395
|
+
const land = useWorldLand(row?.latitude != null && row?.longitude != null);
|
|
396
|
+
|
|
397
|
+
if (isPending) return <div className="modal-pad dim">Loading GeoIP record…</div>;
|
|
398
|
+
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'GeoIP record not found'}</div>;
|
|
399
|
+
|
|
400
|
+
const status = statusOf(row);
|
|
401
|
+
const blocked = blockActive(row);
|
|
402
|
+
const whitelisted = whitelistActive(row);
|
|
403
|
+
const fired = REPUTATION_FLAGS.filter((flag) => Boolean(row[flag.key]));
|
|
404
|
+
const hasCoords = row.latitude != null && row.longitude != null;
|
|
405
|
+
|
|
406
|
+
const editForm = async (title: string, fields: typeof GEOIP_EDIT_LOCATION_FIELDS) => {
|
|
407
|
+
const initial: Record<string, string | boolean> = {};
|
|
408
|
+
for (const field of fields) {
|
|
409
|
+
const value = (row as unknown as Record<string, unknown>)[field.name];
|
|
410
|
+
initial[field.name] = field.type === 'switch' ? Boolean(value) : (value == null ? '' : String(value));
|
|
411
|
+
}
|
|
412
|
+
const data = await formModal({ title: `${title} — ${row.ip_address}`, fields, initial });
|
|
413
|
+
if (!data) return;
|
|
414
|
+
try {
|
|
415
|
+
await save.mutateAsync({ id: row.id, changes: data });
|
|
416
|
+
toast.success(`${title} saved`);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
toast.error(err instanceof Error ? err.message : `Failed to save ${title.toLowerCase()}`);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const doBlock = async () => {
|
|
423
|
+
const data = await formModal({
|
|
424
|
+
title: `Block ${row.ip_address}`,
|
|
425
|
+
fields: GEOIP_BLOCK_FIELDS,
|
|
426
|
+
initial: { reason: '', ttl: '86400' },
|
|
427
|
+
submitText: 'Block',
|
|
428
|
+
});
|
|
429
|
+
if (!data) return;
|
|
430
|
+
try {
|
|
431
|
+
await blockAction.mutateAsync({
|
|
432
|
+
id: row.id,
|
|
433
|
+
payload: { reason: String(data.reason ?? ''), ttl: Number(data.ttl ?? 0) },
|
|
434
|
+
});
|
|
435
|
+
toast.success('IP blocked');
|
|
436
|
+
} catch (err) {
|
|
437
|
+
toast.error(err instanceof Error ? err.message : 'Failed to block IP');
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const doUnblock = async () => {
|
|
442
|
+
const data = await formModal({
|
|
443
|
+
title: `Unblock ${row.ip_address}`,
|
|
444
|
+
fields: GEOIP_UNBLOCK_FIELDS,
|
|
445
|
+
initial: { reason: '' },
|
|
446
|
+
submitText: 'Unblock',
|
|
447
|
+
});
|
|
448
|
+
if (!data) return;
|
|
449
|
+
try {
|
|
450
|
+
// `on_action_unblock` takes a STRING reason, not a dict.
|
|
451
|
+
await unblockAction.mutateAsync({ id: row.id, payload: String(data.reason || 'Unblocked from admin') });
|
|
452
|
+
toast.success('IP unblocked');
|
|
453
|
+
} catch (err) {
|
|
454
|
+
toast.error(err instanceof Error ? err.message : 'Failed to unblock IP');
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const doWhitelist = async () => {
|
|
459
|
+
const data = await formModal({
|
|
460
|
+
title: `Whitelist ${row.ip_address}`,
|
|
461
|
+
fields: GEOIP_WHITELIST_FIELDS,
|
|
462
|
+
initial: { reason: '', ttl: '0' },
|
|
463
|
+
submitText: 'Whitelist',
|
|
464
|
+
});
|
|
465
|
+
if (!data) return;
|
|
466
|
+
const ttl = Number(data.ttl ?? 0);
|
|
467
|
+
try {
|
|
468
|
+
await whitelistAction.mutateAsync({
|
|
469
|
+
id: row.id,
|
|
470
|
+
payload: ttl > 0 ? { reason: String(data.reason ?? ''), ttl } : { reason: String(data.reason ?? '') },
|
|
471
|
+
});
|
|
472
|
+
toast.success('IP whitelisted');
|
|
473
|
+
} catch (err) {
|
|
474
|
+
toast.error(err instanceof Error ? err.message : 'Failed to whitelist IP');
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const doUnwhitelist = async () => {
|
|
479
|
+
const ok = await modal.confirm({
|
|
480
|
+
title: 'Remove whitelist',
|
|
481
|
+
message: <>Remove <code>{row.ip_address}</code> from the whitelist? It becomes eligible for auto-blocking again.</>,
|
|
482
|
+
confirmText: 'Remove',
|
|
483
|
+
danger: true,
|
|
484
|
+
});
|
|
485
|
+
if (!ok) return;
|
|
486
|
+
try {
|
|
487
|
+
await unwhitelistAction.mutateAsync({ id: row.id, payload: 1 });
|
|
488
|
+
toast.success('IP removed from whitelist');
|
|
489
|
+
} catch (err) {
|
|
490
|
+
toast.error(err instanceof Error ? err.message : 'Failed to remove from whitelist');
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const doRefresh = async (kind: 'refresh' | 'threat_analysis') => {
|
|
495
|
+
const action = kind === 'refresh' ? refreshAction : threatAction;
|
|
496
|
+
try {
|
|
497
|
+
await action.mutateAsync({ id: row.id, payload: 1 });
|
|
498
|
+
toast.success(kind === 'refresh' ? 'Geolocation refreshed' : 'Threat data refreshed');
|
|
499
|
+
} catch (err) {
|
|
500
|
+
toast.error(err instanceof Error ? err.message : 'Refresh failed');
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const copy = (label: string, value: string) => {
|
|
505
|
+
void navigator.clipboard?.writeText(value)
|
|
506
|
+
.then(() => toast.success(`${label} copied`))
|
|
507
|
+
.catch(() => toast.error(`Could not copy ${label.toLowerCase()}`));
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
return (
|
|
511
|
+
<DetailView<GeoLocatedIPRow>
|
|
512
|
+
icon="bi-globe2"
|
|
513
|
+
title={row.ip_address}
|
|
514
|
+
subtitle={subtitleOf(row)}
|
|
515
|
+
chips={chipsOf(row)}
|
|
516
|
+
menuContext={row}
|
|
517
|
+
sections={[
|
|
518
|
+
{
|
|
519
|
+
key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
520
|
+
<>
|
|
521
|
+
<Eyebrow>Overview</Eyebrow>
|
|
522
|
+
<StatusPanel
|
|
523
|
+
tone={status.tone}
|
|
524
|
+
state={status.state}
|
|
525
|
+
headline={status.headline}
|
|
526
|
+
meta={status.meta}
|
|
527
|
+
icon={iconTone(row) === 'danger' ? 'bi-shield-exclamation' : undefined}
|
|
528
|
+
actions={canManage ? (
|
|
529
|
+
<>
|
|
530
|
+
{!blocked
|
|
531
|
+
? <button className="btn btn-danger btn-compact" onClick={() => void doBlock()}><i className="bi bi-slash-circle" /> Block</button>
|
|
532
|
+
: <button className="btn btn-compact" onClick={() => void doUnblock()}><i className="bi bi-unlock" /> Unblock</button>}
|
|
533
|
+
{!whitelisted
|
|
534
|
+
? <button className="btn btn-compact" onClick={() => void doWhitelist()}><i className="bi bi-shield-check" /> Whitelist</button>
|
|
535
|
+
: <button className="btn btn-compact" onClick={() => void doUnwhitelist()}><i className="bi bi-x-circle" /> Remove whitelist</button>}
|
|
536
|
+
</>
|
|
537
|
+
) : undefined}
|
|
538
|
+
/>
|
|
539
|
+
|
|
540
|
+
<div className="geoip-kpis">
|
|
541
|
+
<MetricCard label="Risk score" value={row.risk_score != null ? `${row.risk_score} / 100` : '—'} icon="bi-speedometer2" />
|
|
542
|
+
<MetricCard label="Incident events" value={canEvents ? (eventCount ?? '—') : '—'} icon="bi-shield-exclamation" />
|
|
543
|
+
<MetricCard label="Logins from this IP" value={canLogins ? (loginCount ?? '—') : '—'} icon="bi-box-arrow-in-right" />
|
|
544
|
+
<MetricCard label="Devices seen" value={canDevices ? (deviceCount ?? '—') : '—'} icon="bi-laptop" />
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<Eyebrow>Location & network</Eyebrow>
|
|
548
|
+
<FlatRow label="Country"><CountryLabel row={row} /></FlatRow>
|
|
549
|
+
<FlatRow label="Region · City">{text([row.region, row.city].filter(Boolean).join(' · '))}</FlatRow>
|
|
550
|
+
<FlatRow label="Coordinates">{hasCoords ? <code>{row.latitude}, {row.longitude}</code> : DASH}</FlatRow>
|
|
551
|
+
<FlatRow label="ASN · ISP">
|
|
552
|
+
{row.asn || row.isp
|
|
553
|
+
? <>{row.asn && <code>{row.asn}</code>}{row.asn_org ? ` ${row.asn_org}` : ''}{row.isp ? `${row.asn ? ' · ' : ''}${row.isp}` : ''}</>
|
|
554
|
+
: DASH}
|
|
555
|
+
</FlatRow>
|
|
556
|
+
<FlatRow label="Last seen">{row.last_seen != null ? fmt.datetime(row.last_seen) : DASH}</FlatRow>
|
|
557
|
+
|
|
558
|
+
{hasCoords && (
|
|
559
|
+
<div className="geoip-map">
|
|
560
|
+
<WorldMap
|
|
561
|
+
height={160}
|
|
562
|
+
land={land}
|
|
563
|
+
interactive={false}
|
|
564
|
+
showLegend={false}
|
|
565
|
+
markers={[{
|
|
566
|
+
id: `geoip-${row.id}`,
|
|
567
|
+
lat: row.latitude!,
|
|
568
|
+
lng: row.longitude!,
|
|
569
|
+
size: 16,
|
|
570
|
+
tone: threatTone(row.threat_level) === 'danger' ? 'bad' : 'accent',
|
|
571
|
+
label: row.ip_address,
|
|
572
|
+
detail: [row.city, row.region, row.country_name].filter(Boolean).join(', ') || undefined,
|
|
573
|
+
}]}
|
|
574
|
+
status="Approximate location — city-level accuracy at best"
|
|
575
|
+
/>
|
|
576
|
+
</div>
|
|
577
|
+
)}
|
|
578
|
+
</>
|
|
579
|
+
),
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
key: 'network', label: 'Network', icon: 'bi-diagram-3', render: () => (
|
|
583
|
+
<>
|
|
584
|
+
<Eyebrow>Identity</Eyebrow>
|
|
585
|
+
<FlatRow label="IP address"><code>{row.ip_address}</code></FlatRow>
|
|
586
|
+
<FlatRow label="Subnet">{row.subnet ? <code>{row.subnet}</code> : DASH}</FlatRow>
|
|
587
|
+
{/* `reverse_dns` and `ip_version` do not exist on
|
|
588
|
+
GeoLocatedIP — the source's two rows here were
|
|
589
|
+
permanently blank and are not carried. */}
|
|
590
|
+
|
|
591
|
+
<Eyebrow>Carrier · ASN · ISP</Eyebrow>
|
|
592
|
+
<FlatRow label="ASN">{row.asn ? <code>{row.asn}</code> : DASH}</FlatRow>
|
|
593
|
+
<FlatRow label="ASN org">{text(row.asn_org)}</FlatRow>
|
|
594
|
+
<FlatRow label="ISP">{text(row.isp)}</FlatRow>
|
|
595
|
+
<FlatRow label="Connection">{text(row.connection_type)}</FlatRow>
|
|
596
|
+
<FlatRow label="Mobile carrier">{text(row.mobile_carrier)}</FlatRow>
|
|
597
|
+
|
|
598
|
+
<Eyebrow>Hosting flags</Eyebrow>
|
|
599
|
+
<FlatRow label="Cloud provider"><YesNo value={row.is_cloud} /></FlatRow>
|
|
600
|
+
<FlatRow label="Datacenter"><YesNo value={row.is_datacenter} /></FlatRow>
|
|
601
|
+
<FlatRow label="Mobile"><YesNo value={row.is_mobile} /></FlatRow>
|
|
602
|
+
<FlatRow label="VPN"><YesNo value={row.is_vpn} /></FlatRow>
|
|
603
|
+
<FlatRow label="Tor exit"><YesNo value={row.is_tor} /></FlatRow>
|
|
604
|
+
<FlatRow label="Proxy"><YesNo value={row.is_proxy} /></FlatRow>
|
|
605
|
+
</>
|
|
606
|
+
),
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
key: 'risk', label: 'Risk & Reputation', icon: 'bi-shield-exclamation', render: () => (
|
|
610
|
+
<>
|
|
611
|
+
<Eyebrow>Summary</Eyebrow>
|
|
612
|
+
<FlatRow label="Threat level">
|
|
613
|
+
<Badge tone={threatTone(row.threat_level) as Tone}>{row.threat_level || 'unknown'}</Badge>
|
|
614
|
+
</FlatRow>
|
|
615
|
+
<FlatRow label="Risk score">
|
|
616
|
+
{row.risk_score != null ? <><strong>{row.risk_score}</strong> / 100</> : DASH}
|
|
617
|
+
</FlatRow>
|
|
618
|
+
<FlatRow label="Last checked">{row.last_seen != null ? fmt.relative(row.last_seen) : DASH}</FlatRow>
|
|
619
|
+
<FlatRow label="Cache expires">{row.expires_at != null ? fmt.datetime(row.expires_at) : <span className="dim-italic">Never (internal record)</span>}</FlatRow>
|
|
620
|
+
|
|
621
|
+
<Eyebrow>Reputation flags</Eyebrow>
|
|
622
|
+
{fired.length === 0
|
|
623
|
+
? <FlatRow label="Status"><span className="dim-italic">No reputation flags fired.</span></FlatRow>
|
|
624
|
+
: fired.map((flag) => (
|
|
625
|
+
<FlatRow key={String(flag.key)} label={flag.label}>
|
|
626
|
+
<Badge tone={flag.tone}><i className={`bi ${flag.icon}`} /> {flag.title}</Badge>
|
|
627
|
+
<span className="dim"> · {flag.detail}</span>
|
|
628
|
+
</FlatRow>
|
|
629
|
+
))}
|
|
630
|
+
</>
|
|
631
|
+
),
|
|
632
|
+
},
|
|
633
|
+
{ divider: 'Enforcement' },
|
|
634
|
+
{
|
|
635
|
+
key: 'enforcement', label: 'Block & Whitelist', icon: 'bi-slash-circle', render: () => (
|
|
636
|
+
<>
|
|
637
|
+
<Eyebrow>Block</Eyebrow>
|
|
638
|
+
<FlatRow label="Status">
|
|
639
|
+
{blocked
|
|
640
|
+
? <Badge tone="danger"><i className="bi bi-slash-circle" /> Blocked</Badge>
|
|
641
|
+
: blockExpired(row)
|
|
642
|
+
? <Badge tone="warning"><i className="bi bi-hourglass-bottom" /> Block expired</Badge>
|
|
643
|
+
: <Badge tone="success"><i className="bi bi-check2" /> Allowed</Badge>}
|
|
644
|
+
</FlatRow>
|
|
645
|
+
<FlatRow label="Reason">{text(row.blocked_reason)}</FlatRow>
|
|
646
|
+
<FlatRow label="Blocked at">{row.blocked_at != null ? <code>{fmt.datetime(row.blocked_at)}</code> : DASH}</FlatRow>
|
|
647
|
+
<FlatRow label="Blocked until">
|
|
648
|
+
{row.blocked_until != null
|
|
649
|
+
? <><code>{fmt.datetime(row.blocked_until)}</code>{blockExpired(row) && <span className="text-warn"> · expired</span>}</>
|
|
650
|
+
: <span className="dim-italic">Permanent / —</span>}
|
|
651
|
+
</FlatRow>
|
|
652
|
+
<FlatRow label="Block count">{row.block_count ?? 0}</FlatRow>
|
|
653
|
+
{canManage && (
|
|
654
|
+
<div className="geoip-actions">
|
|
655
|
+
{!blocked
|
|
656
|
+
? <button className="btn btn-compact" onClick={() => void doBlock()}><i className="bi bi-slash-circle" /> Block this IP…</button>
|
|
657
|
+
: <ArmedButton
|
|
658
|
+
label="Unblock"
|
|
659
|
+
armedLabel="Click again — traffic resumes fleet-wide"
|
|
660
|
+
icon="bi-unlock"
|
|
661
|
+
className="btn-compact"
|
|
662
|
+
onConfirm={() => doUnblock()}
|
|
663
|
+
/>}
|
|
664
|
+
</div>
|
|
665
|
+
)}
|
|
666
|
+
|
|
667
|
+
<Eyebrow>Whitelist</Eyebrow>
|
|
668
|
+
<FlatRow label="Status">
|
|
669
|
+
{whitelisted
|
|
670
|
+
? <Badge tone="info"><i className="bi bi-shield-check" /> Whitelisted</Badge>
|
|
671
|
+
: whitelistExpired(row)
|
|
672
|
+
? <Badge tone="warning"><i className="bi bi-hourglass-bottom" /> Whitelist expired</Badge>
|
|
673
|
+
: <Badge tone="muted">Not whitelisted</Badge>}
|
|
674
|
+
</FlatRow>
|
|
675
|
+
<FlatRow label="Reason">{text(row.whitelisted_reason)}</FlatRow>
|
|
676
|
+
<FlatRow label="Whitelisted until">
|
|
677
|
+
{row.whitelisted_until != null
|
|
678
|
+
? <><code>{fmt.datetime(row.whitelisted_until)}</code>{whitelistExpired(row) && <span className="text-warn"> · expired</span>}</>
|
|
679
|
+
: <span className="dim-italic">Permanent / —</span>}
|
|
680
|
+
</FlatRow>
|
|
681
|
+
{canManage && (
|
|
682
|
+
<div className="geoip-actions">
|
|
683
|
+
{!whitelisted
|
|
684
|
+
? <button className="btn btn-compact" onClick={() => void doWhitelist()}><i className="bi bi-shield-check" /> Whitelist this IP…</button>
|
|
685
|
+
: <ArmedButton
|
|
686
|
+
label="Remove whitelist"
|
|
687
|
+
armedLabel="Click again — auto-blocking resumes"
|
|
688
|
+
icon="bi-x-circle"
|
|
689
|
+
className="btn-compact"
|
|
690
|
+
onConfirm={() => doUnwhitelist()}
|
|
691
|
+
/>}
|
|
692
|
+
</div>
|
|
693
|
+
)}
|
|
694
|
+
</>
|
|
695
|
+
),
|
|
696
|
+
},
|
|
697
|
+
{ divider: 'Activity' },
|
|
698
|
+
{
|
|
699
|
+
key: 'activity', label: 'Activity', icon: 'bi-list-ul',
|
|
700
|
+
render: () => (
|
|
701
|
+
<ActivitySection
|
|
702
|
+
ip={row.ip_address}
|
|
703
|
+
canEvents={canEvents}
|
|
704
|
+
canLogs={canLogs}
|
|
705
|
+
canLogins={canLogins}
|
|
706
|
+
canDevices={canDevices}
|
|
707
|
+
onOpenLogin={onOpenLogin}
|
|
708
|
+
onOpenLocation={onOpenLocation}
|
|
709
|
+
/>
|
|
710
|
+
),
|
|
711
|
+
},
|
|
712
|
+
{ divider: 'Detail' },
|
|
713
|
+
{
|
|
714
|
+
key: 'metadata', label: 'Metadata', icon: 'bi-braces', render: () => (
|
|
715
|
+
<>
|
|
716
|
+
<Eyebrow>Metadata</Eyebrow>
|
|
717
|
+
{/* GeoLocatedIP has NO `metadata` field — the card
|
|
718
|
+
is fed the record itself, which is what the
|
|
719
|
+
source actually wanted. */}
|
|
720
|
+
<KnownFieldsCard
|
|
721
|
+
data={row as unknown as Record<string, unknown>}
|
|
722
|
+
known={[
|
|
723
|
+
{ key: 'id', label: 'Record ID' },
|
|
724
|
+
{ key: 'ip_address', label: 'IP address' },
|
|
725
|
+
{ key: 'created', label: 'Created', format: 'datetime' },
|
|
726
|
+
{ key: 'modified', label: 'Modified', format: 'datetime' },
|
|
727
|
+
{ key: 'last_seen', label: 'Last seen', format: 'datetime' },
|
|
728
|
+
{ key: 'expires_at', label: 'Expires at', format: 'datetime' },
|
|
729
|
+
]}
|
|
730
|
+
showRaw={false}
|
|
731
|
+
/>
|
|
732
|
+
<Eyebrow>Raw provider record</Eyebrow>
|
|
733
|
+
<RawRecordReveal id={row.id} canManage={canManage} />
|
|
734
|
+
</>
|
|
735
|
+
),
|
|
736
|
+
},
|
|
737
|
+
]}
|
|
738
|
+
initialSection="overview"
|
|
739
|
+
contextMenu={[
|
|
740
|
+
{ label: 'Refresh geolocation', icon: 'bi-arrow-clockwise', permissions: GEOIP_MANAGE_PERMS, onSelect: () => void doRefresh('refresh') },
|
|
741
|
+
{ label: 'Refresh threat data', icon: 'bi-shield-exclamation', permissions: GEOIP_MANAGE_PERMS, onSelect: () => void doRefresh('threat_analysis') },
|
|
742
|
+
{ divider: true },
|
|
743
|
+
{ label: 'Edit location', icon: 'bi-geo-alt', permissions: GEOIP_MANAGE_PERMS, onSelect: () => void editForm('Edit location', GEOIP_EDIT_LOCATION_FIELDS) },
|
|
744
|
+
{ label: 'Edit network', icon: 'bi-diagram-3', permissions: GEOIP_MANAGE_PERMS, onSelect: () => void editForm('Edit network', GEOIP_EDIT_NETWORK_FIELDS) },
|
|
745
|
+
{ label: 'Edit security', icon: 'bi-shield-lock', permissions: GEOIP_MANAGE_PERMS, onSelect: () => void editForm('Edit security', GEOIP_EDIT_SECURITY_FIELDS) },
|
|
746
|
+
{ divider: true },
|
|
747
|
+
{ label: 'Block 24h', icon: 'bi-slash-circle', permissions: GEOIP_MANAGE_PERMS, when: (ctx) => !blockActive(ctx), onSelect: () => void doBlock() },
|
|
748
|
+
{ label: 'Unblock', icon: 'bi-unlock', permissions: GEOIP_MANAGE_PERMS, when: (ctx) => blockActive(ctx), onSelect: () => void doUnblock() },
|
|
749
|
+
{ label: 'Whitelist', icon: 'bi-shield-check', permissions: GEOIP_MANAGE_PERMS, when: (ctx) => !whitelistActive(ctx), onSelect: () => void doWhitelist() },
|
|
750
|
+
{ label: 'Remove whitelist', icon: 'bi-x-circle', permissions: GEOIP_MANAGE_PERMS, when: (ctx) => whitelistActive(ctx), onSelect: () => void doUnwhitelist() },
|
|
751
|
+
{ divider: true },
|
|
752
|
+
{ label: 'Copy IP', icon: 'bi-clipboard', onSelect: () => copy('IP address', row.ip_address) },
|
|
753
|
+
{
|
|
754
|
+
label: 'Refresh record', icon: 'bi-arrow-repeat',
|
|
755
|
+
onSelect: () => { void qc.invalidateQueries({ queryKey: GeoLocatedIPModel.keys.one(row.id) }); },
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
label: 'Open coordinates in Google Maps', icon: 'bi-box-arrow-up-right',
|
|
759
|
+
when: () => hasCoords,
|
|
760
|
+
// The ONLY third-party egress in this surface, and it is
|
|
761
|
+
// operator-initiated, never automatic.
|
|
762
|
+
onSelect: () => { window.open(`https://www.google.com/maps/search/?api=1&query=${row.latitude},${row.longitude}`, '_blank', 'noopener,noreferrer'); },
|
|
763
|
+
},
|
|
764
|
+
// NO Delete: GeoLocatedIP declares no CAN_DELETE, so rest.py
|
|
765
|
+
// refuses every DELETE. The source's item could never work.
|
|
766
|
+
]}
|
|
767
|
+
onClose={onClose}
|
|
768
|
+
/>
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Open the dossier for a known record id. Exported for #1287 and the device surfaces. */
|
|
773
|
+
export function showGeoIpDossier(id: number, opts: Omit<GeoIpDossierProps, 'id' | 'onClose'> = {}): void {
|
|
774
|
+
void modal.detail((close) => <GeoIpDossier id={id} onClose={() => close(null)} {...opts} />);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Open the dossier for a raw IP string. Resolution goes through the CACHE
|
|
779
|
+
* TABLE (`?ip_address=`), which is read-only and unmetered — NOT through
|
|
780
|
+
* `/geoip/lookup`, which creates records, calls the provider and is rate
|
|
781
|
+
* limited. Inspecting an IP must never cost a provider call.
|
|
782
|
+
*/
|
|
783
|
+
export function GeoIpDossierForAddress({ ip, onClose }: { ip: string; onClose: () => void }) {
|
|
784
|
+
const { data, isPending, error } = useGeoIpByAddress(ip);
|
|
785
|
+
if (isPending) return <div className="modal-pad dim">Looking up {ip}…</div>;
|
|
786
|
+
if (error) return <div className="modal-pad text-bad">{error.message}</div>;
|
|
787
|
+
if (!data) {
|
|
788
|
+
return (
|
|
789
|
+
<div className="modal-pad">
|
|
790
|
+
<h2 className="modal-title">No cached geolocation</h2>
|
|
791
|
+
<p className="dim">
|
|
792
|
+
Nothing is cached for <code>{ip}</code>. Use <strong>Look up IP</strong> on the
|
|
793
|
+
GeoIP Cache page to fetch it from the provider.
|
|
794
|
+
</p>
|
|
795
|
+
<div className="modal-actions"><button className="btn" onClick={onClose}>Close</button></div>
|
|
796
|
+
</div>
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
return <GeoIpDossier id={data.id} onClose={onClose} />;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function showGeoIpDossierForAddress(ip: string): void {
|
|
803
|
+
void modal.detail((close) => <GeoIpDossierForAddress ip={ip} onClose={() => close(null)} />);
|
|
804
|
+
}
|